Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc72c5f6e9 | |||
| 51a399c775 | |||
| fc75eeb76d | |||
| 37134b8c18 | |||
| c6b804bf0a | |||
| c754984df8 | |||
| 906aab3b73 | |||
| e1aaf9f6fc | |||
| 6170306d9e | |||
| 04cd9ff472 | |||
| 1bf42a7feb | |||
| 56dd7d3fd3 | |||
| 8837031fd2 | |||
| a0cfbbc2d2 | |||
| 280a6ff2fa | |||
| 78e808bc54 | |||
| c1445976d7 | |||
| ff8aa2a4f5 | |||
| 398436ecb5 | |||
| 06a4a27880 | |||
| f34c733706 | |||
| 95efa5d029 | |||
| 9a036f1968 | |||
| ec6d4ad496 | |||
| d70b8e2363 | |||
| ee3c5af7a4 | |||
| b02cd978c3 | |||
| e956d7c30d | |||
| b8fc4e6642 | |||
| ab1843b1c3 | |||
| 88a973dc68 | |||
| 27c77af591 | |||
| e7587ca9f5 | |||
| 59b125d8e2 | |||
| b05de96569 | |||
| a5d64d1859 | |||
| 5bba2bbd92 | |||
| 986091ac56 | |||
| 47b1af8e92 | |||
| d567225bf7 | |||
| d2b6bba15c | |||
| b7396d58d2 | |||
| 351e76c00d | |||
| 2c2c4f3683 | |||
| fdf78353ad | |||
| 4cc433a1b8 | |||
| 321b4e3d0e | |||
| 2e156fc534 | |||
| cc52811522 | |||
| 9cab8c7bc7 | |||
| eeb7d9a1b2 | |||
| 6cf06d2380 | |||
| 401ad2e65d | |||
| 4814ec2363 | |||
| 98b9d612fa | |||
| 874873541d | |||
| ef9ac76f06 | |||
| ca9db195de | |||
| c1e16f2163 | |||
| 61d32fa00f | |||
| cddb3f8ccf | |||
| 87a938fe58 | |||
| 9157694412 | |||
| aa34314175 | |||
| c7fc386d0f | |||
| 6bd814e346 | |||
| aa25852091 | |||
| c2740cd282 | |||
| 23875bb3cc | |||
| ee1eab8408 | |||
| 2254ba7496 | |||
| 4866ad08b1 | |||
| 97ebc19313 | |||
| 946ac6f66a | |||
| 90ddee14c2 | |||
| f17f8ae8c8 | |||
| d713bfc5f9 | |||
| 27fe8c24ec | |||
| eef1e4e8c6 | |||
| a7a5905874 | |||
| 021537de56 | |||
| fdfd75790d | |||
| 3d1f8d9cf7 | |||
| eec37ab710 | |||
| 5f499ec1b0 | |||
| 2b5223097f | |||
| 1efbc289ba | |||
| 3c57c8b78b | |||
| 9c4500f9cb | |||
| 1e2c5a68cf | |||
| dc6991e6ef | |||
| cdf233378c | |||
| 23769e6ad4 | |||
| 9f8058223a | |||
| b483a34517 | |||
| a8fbca9ef5 | |||
| de8c47c81c | |||
| b11089896a | |||
| 16549709e2 | |||
| 68977b73be | |||
| 3da2bc93cb | |||
| d9632a3412 | |||
| 03d22c4d06 | |||
| 19242b4152 | |||
| ceaed9af66 | |||
| e9364fa70f | |||
| 2bec205a30 | |||
| cbd3436ff7 | |||
| 57ff236f2d | |||
| 6085859874 | |||
| d413fb84a5 | |||
| c22b047b8c | |||
| 090edf7ef6 | |||
| cbaebcf649 | |||
| 4a0d38384f | |||
| ea006b68c2 | |||
| 202533fbb1 | |||
| 0952aa8217 | |||
| 787e8844bc | |||
| fe98f966d6 | |||
| 79ad3b0715 | |||
| f728011b2a | |||
| 569876538a | |||
| d2b1c132d1 | |||
| 8926152fca | |||
| 2682e0268c | |||
| f13a63dc2f | |||
| 4a7f24348c | |||
| 0fdbef578f | |||
| 29a12bb102 | |||
| 270764ff0f | |||
| 0e6521e433 | |||
| e20d94d6ba | |||
| f4802ece4d | |||
| 9800e37cd6 | |||
| 84f30b07c4 | |||
| fba5e7c7be | |||
| 1e7bd0a540 | |||
| 0a0af4e02a |
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"fingerprint": "c324de9e9faf30231900c691aca5f3a07c7db099"
|
"fingerprint": "c36b11ec5edebc02aa51b1113a7a11dc2559e812"
|
||||||
}
|
}
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
|
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
|
||||||
|
|
||||||
Last updated: 2026-05-27
|
Last updated: 2026-06-02
|
||||||
|
|
||||||
## Sources scanned
|
## Sources scanned
|
||||||
|
|
||||||
- .opencode/skills
|
- .opencode/skills
|
||||||
|
- .claude/skills
|
||||||
- /home/alex/.config/opencode/skills
|
- /home/alex/.config/opencode/skills
|
||||||
|
|
||||||
## Contract
|
## Contract
|
||||||
@@ -20,11 +21,11 @@ Last updated: 2026-05-27
|
|||||||
| Skill | Trigger / description | Scope | Path |
|
| Skill | Trigger / description | Scope | Path |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `auto-commit` | Use when you are making multiple edits or completing significant work in a git repository to automatically create commits | user | `/home/alex/.config/opencode/skills/auto-commit/SKILL.md` |
|
| `auto-commit` | Use when you are making multiple edits or completing significant work in a git repository to automatically create commits | user | `/home/alex/.config/opencode/skills/auto-commit/SKILL.md` |
|
||||||
| `openspec` | Use OpenSpec as the source of truth for planning, implementation, verification, and archive discipline. | user | `/home/alex/.config/opencode/skills/openspec/SKILL.md` |
|
|
||||||
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-apply-change/SKILL.md` |
|
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-apply-change/SKILL.md` |
|
||||||
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-archive-change/SKILL.md` |
|
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-archive-change/SKILL.md` |
|
||||||
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-explore/SKILL.md` |
|
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-explore/SKILL.md` |
|
||||||
| `openspec-propose` | Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-propose/SKILL.md` |
|
| `openspec-propose` | Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-propose/SKILL.md` |
|
||||||
|
| `sift-backlog` | Triage and organize backlog tasks into actionable plans. Use when asked to review the backlog, prioritize tasks, create plans from backlog items, or move tasks from backlog to open status. Handles the full workflow of listing backlog tasks, grouping related tasks into plans, setting priorities and dependencies, activating plans, and changing task status from backlog to open. | project | `/home/alex/projects/headquarter/.claude/skills/sift-backlog/SKILL.md` |
|
||||||
|
|
||||||
## Loading protocol
|
## Loading protocol
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
OpenSpec is the source of truth. Superpowers is the default workflow. Keep changes small, scoped, and verified.
|
OpenSpec is the source of truth. Superpowers is the default workflow. Keep changes small, scoped, and verified.
|
||||||
|
|
||||||
|
## Communication
|
||||||
|
|
||||||
|
All agent output, code comments, commit messages, documentation, and artifacts must be in **English** unless the user explicitly requests another language.
|
||||||
|
|
||||||
## Priority order
|
## Priority order
|
||||||
|
|
||||||
1. Current user instruction
|
1. Current user instruction
|
||||||
@@ -71,6 +75,7 @@ Do not:
|
|||||||
* Introduce new dependencies without clear justification.
|
* Introduce new dependencies without clear justification.
|
||||||
* Treat existing code as more authoritative than OpenSpec for intended behavior.
|
* Treat existing code as more authoritative than OpenSpec for intended behavior.
|
||||||
* Decide product behavior silently when the spec is unclear.
|
* Decide product behavior silently when the spec is unclear.
|
||||||
|
* Run `docker compose` commands (build, up, down, etc.) without explicit user approval and proper isolation (e.g., feature branches, separate worktrees, or staged rollouts). Docker Compose operations are deployment-level changes that can affect running services, shared volumes, and network state. Always ask first.
|
||||||
|
|
||||||
If scope must change, propose an OpenSpec update first.
|
If scope must change, propose an OpenSpec update first.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -50,8 +50,8 @@ ENV PATH=/root/.local/bin:$PATH
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY --chown=appuser:appgroup . .
|
COPY --chown=appuser:appgroup . .
|
||||||
|
|
||||||
# Create directories for repo and instance storage
|
# Create directories for repo, instance, and workspace storage
|
||||||
RUN mkdir -p /data/repos /data/instances && chown -R appuser:appgroup /data
|
RUN mkdir -p /data/repos /data/instances /data/working-copies && chown -R appuser:appgroup /data
|
||||||
|
|
||||||
# Copy wait-for-db script
|
# Copy wait-for-db script
|
||||||
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""add_ssh_key_id_to_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 069d3da4dc9b
|
||||||
|
Revises: 2026_05_29_add_notifications_table
|
||||||
|
Create Date: 2026-05-29 12:30:16.580532
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "069d3da4dc9b"
|
||||||
|
down_revision = "2026_05_29_add_notifications_table"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column(
|
||||||
|
"ssh_key_id",
|
||||||
|
sa.Uuid(),
|
||||||
|
sa.ForeignKey("ssh_keys.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("config_profiles", "ssh_key_id")
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""add monitoring tables
|
||||||
|
|
||||||
|
Revision ID: 2026_05_28_add_monitoring_tables
|
||||||
|
Revises: 2026_05_28_drop_tool_configs_and_config_folders
|
||||||
|
Create Date: 2026-05-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_28_add_monitoring_tables"
|
||||||
|
down_revision: str | None = "2026_05_28_drop_tool_configs_and_config_folders"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"instance_events",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"instance_id",
|
||||||
|
sa.Uuid(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("event_type", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"metadata",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="{}",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["instance_id"],
|
||||||
|
["tool_instances.id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["created_by"],
|
||||||
|
["users.id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_instance_events_instance_id",
|
||||||
|
"instance_events",
|
||||||
|
["instance_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_instance_events_created_at",
|
||||||
|
"instance_events",
|
||||||
|
["created_at"],
|
||||||
|
postgresql_using="btree",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_instance_events_event_type",
|
||||||
|
"instance_events",
|
||||||
|
["event_type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"health_checks",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"instance_id",
|
||||||
|
sa.Uuid(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("container_status", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("container_healthy", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("tunnel_healthy", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("exit_code", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("probe_status", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("probe_output", sa.Text(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"checked_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["instance_id"],
|
||||||
|
["tool_instances.id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_health_checks_instance_id",
|
||||||
|
"health_checks",
|
||||||
|
["instance_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_health_checks_checked_at",
|
||||||
|
"health_checks",
|
||||||
|
["checked_at"],
|
||||||
|
postgresql_using="btree",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_health_checks_checked_at", table_name="health_checks")
|
||||||
|
op.drop_index("idx_health_checks_instance_id", table_name="health_checks")
|
||||||
|
op.drop_table("health_checks")
|
||||||
|
op.drop_index("idx_instance_events_event_type", table_name="instance_events")
|
||||||
|
op.drop_index("idx_instance_events_created_at", table_name="instance_events")
|
||||||
|
op.drop_index("idx_instance_events_instance_id", table_name="instance_events")
|
||||||
|
op.drop_table("instance_events")
|
||||||
@@ -232,14 +232,6 @@ def upgrade() -> None:
|
|||||||
"writable": True,
|
"writable": True,
|
||||||
"owner": "user",
|
"owner": "user",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "ssh_keys",
|
|
||||||
"target": "/home/user/.ssh",
|
|
||||||
"source_type": "ssh_key",
|
|
||||||
"mode": "0700",
|
|
||||||
"file_mode": "0600",
|
|
||||||
"readonly": True,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "pi_state",
|
"name": "pi_state",
|
||||||
"target": "/tmp/.pi/agents",
|
"target": "/tmp/.pi/agents",
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""add notifications table
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_add_notifications_table
|
||||||
|
Revises: 2026_05_28_add_monitoring_tables
|
||||||
|
Create Date: 2026-05-29
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_29_add_notifications_table"
|
||||||
|
down_revision: str | None = "2026_05_28_add_monitoring_tables"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"notifications",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("category", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("severity", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("source_type", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("source_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"metadata",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="{}",
|
||||||
|
),
|
||||||
|
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("dismissed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"],
|
||||||
|
["users.id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_notifications_user_created_at",
|
||||||
|
"notifications",
|
||||||
|
["user_id", sa.text("created_at DESC")],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_notifications_user_unread",
|
||||||
|
"notifications",
|
||||||
|
["user_id", "read_at"],
|
||||||
|
postgresql_where=sa.text("read_at IS NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_notifications_user_unread", table_name="notifications")
|
||||||
|
op.drop_index("idx_notifications_user_created_at", table_name="notifications")
|
||||||
|
op.drop_table("notifications")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""add_ssh_key_ids_to_tool_instances
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_add_ssh_key_ids_to_tool_instances
|
||||||
|
Revises: 2026_05_29_drop_ssh_key_id_from_config_profiles
|
||||||
|
Create Date: 2026-05-29 12:46:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "2026_05_29_add_ssh_key_ids_to_tool_instances"
|
||||||
|
down_revision = "2026_05_29_drop_ssh_key_id_from_config_profiles"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("ssh_key_ids", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_instances", "ssh_key_ids")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""drop_ssh_key_id_from_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_drop_ssh_key_id_from_config_profiles
|
||||||
|
Revises: 069d3da4dc9b
|
||||||
|
Create Date: 2026-05-29 12:45:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "2026_05_29_drop_ssh_key_id_from_config_profiles"
|
||||||
|
down_revision = "069d3da4dc9b"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("config_profiles", "ssh_key_id")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column(
|
||||||
|
"ssh_key_id",
|
||||||
|
sa.Uuid(),
|
||||||
|
sa.ForeignKey("ssh_keys.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""fix code-server bind-addr to host in DB template
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_fix_code_server_bind_addr
|
||||||
|
Revises: 2026_05_29_fix_web_tool_bind_address
|
||||||
|
Create Date: 2026-05-29 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_29_fix_code_server_bind_addr"
|
||||||
|
down_revision: str | None = "2026_05_29_fix_web_tool_bind_address"
|
||||||
|
branch_labels: Sequence[str] | None = None
|
||||||
|
depends_on: Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Find code-server tool types with broken --bind-addr in compose template
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_template
|
||||||
|
FROM tool_types
|
||||||
|
WHERE name = 'code-server'
|
||||||
|
AND compose_template LIKE '%--bind-addr%'
|
||||||
|
""")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for tool_id, compose_template in result:
|
||||||
|
updated = compose_template.replace(
|
||||||
|
"--bind-addr 0.0.0.0:8443", "--host 0.0.0.0"
|
||||||
|
).replace("--bind-addr", "--host 0.0.0.0")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET compose_template = :compose_template
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"compose_template": updated, "id": tool_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Fixed code-server template ({tool_id}): replaced --bind-addr with --host"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Fix code-server bind address to include port
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_fix_code_server_bind_addr_port
|
||||||
|
Revises: 2026_05_29_remove_lsio_command_override
|
||||||
|
Create Date: 2026-05-29 18:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_29_fix_code_server_bind_addr_port"
|
||||||
|
down_revision: Union[str, None] = "2026_05_29_remove_lsio_command_override"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_tool_type_templates(conn) -> None:
|
||||||
|
"""Fix code-server tool type templates with broken --host override."""
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_template, default_port
|
||||||
|
FROM tool_types
|
||||||
|
WHERE name = 'code-server'
|
||||||
|
AND compose_template LIKE '%--host%'
|
||||||
|
""")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for tool_id, compose_template, default_port in result:
|
||||||
|
port = default_port or 8443
|
||||||
|
expected = f"--bind-addr 0.0.0.0:{port}"
|
||||||
|
|
||||||
|
# Replace any line containing --host with the correct bind-addr
|
||||||
|
lines = compose_template.split("\n")
|
||||||
|
new_lines = []
|
||||||
|
modified = False
|
||||||
|
for line in lines:
|
||||||
|
if "command:" in line and "--host" in line:
|
||||||
|
indent = line[: len(line) - len(line.lstrip())]
|
||||||
|
new_lines.append(f"{indent}command: {expected}")
|
||||||
|
modified = True
|
||||||
|
else:
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
if not modified:
|
||||||
|
continue
|
||||||
|
|
||||||
|
updated = "\n".join(new_lines)
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET compose_template = :compose_template
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"compose_template": updated, "id": tool_id},
|
||||||
|
)
|
||||||
|
print(f"Fixed code-server template ({tool_id}): replaced --host with {expected}")
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_instance_compose_files(conn) -> None:
|
||||||
|
"""Fix existing instance compose files on disk with broken --host override."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Use information_schema to check if compose_path column exists
|
||||||
|
col_result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_instances'
|
||||||
|
AND column_name = 'compose_path'
|
||||||
|
""")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not col_result:
|
||||||
|
print("compose_path column not found, skipping instance file fixes")
|
||||||
|
return
|
||||||
|
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_path, tool_type_id
|
||||||
|
FROM tool_instances
|
||||||
|
WHERE compose_path IS NOT NULL
|
||||||
|
""")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for instance_id, compose_path, tool_type_id in result:
|
||||||
|
path = Path(compose_path)
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = path.read_text()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "--host" not in content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get default_port from tool_type
|
||||||
|
port_result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT default_port FROM tool_types WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"id": tool_type_id},
|
||||||
|
).fetchone()
|
||||||
|
port = port_result[0] if port_result and port_result[0] else 8443
|
||||||
|
expected = f"--bind-addr 0.0.0.0:{port}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(content)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not data or "services" not in data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
modified = False
|
||||||
|
for svc in data["services"].values():
|
||||||
|
if "command" in svc:
|
||||||
|
cmd = svc["command"]
|
||||||
|
if "--host" in cmd:
|
||||||
|
svc["command"] = expected
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
if not modified:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
path.write_text(yaml.dump(data, default_flow_style=False))
|
||||||
|
print(
|
||||||
|
f"Fixed code-server instance compose ({instance_id}): "
|
||||||
|
f"replaced --host with {expected}"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Failed to fix instance {instance_id}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
_fix_tool_type_templates(conn)
|
||||||
|
_fix_instance_compose_files(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""fix web tool bind address to 0.0.0.0
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_fix_web_tool_bind_address
|
||||||
|
Revises: 2026_05_29_remove_ssh_keys_mount_from_manifest
|
||||||
|
Create Date: 2026-05-29 14:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_29_fix_web_tool_bind_address"
|
||||||
|
down_revision: Union[str, None] = "2026_05_29_remove_ssh_keys_mount_from_manifest"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_code_server_compose(conn) -> None:
|
||||||
|
"""Update code-server compose template to bind to 0.0.0.0."""
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_template, definition_type
|
||||||
|
FROM tool_types
|
||||||
|
WHERE name = 'code-server'
|
||||||
|
""")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
tool_id, compose_template, definition_type = result
|
||||||
|
|
||||||
|
if definition_type != "compose" or not compose_template:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fix or add command to bind to 0.0.0.0
|
||||||
|
lines = compose_template.split("\n")
|
||||||
|
new_lines = []
|
||||||
|
image_line_idx = -1
|
||||||
|
command_fixed = False
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
# Replace broken --bind-addr with correct --host
|
||||||
|
if "command:" in line and "--bind-addr" in line:
|
||||||
|
indent = line[: len(line) - len(line.lstrip())]
|
||||||
|
new_lines.append(f"{indent}command: --host 0.0.0.0")
|
||||||
|
command_fixed = True
|
||||||
|
continue
|
||||||
|
new_lines.append(line)
|
||||||
|
if "image:" in line and image_line_idx == -1:
|
||||||
|
image_line_idx = i
|
||||||
|
|
||||||
|
# If no command line exists, insert one after image
|
||||||
|
if not command_fixed and image_line_idx != -1:
|
||||||
|
image_line = lines[image_line_idx]
|
||||||
|
indent = image_line[: len(image_line) - len(image_line.lstrip())]
|
||||||
|
# Insert after the image line in new_lines
|
||||||
|
insert_idx = new_lines.index(image_line) + 1
|
||||||
|
new_lines.insert(insert_idx, f"{indent}command: --host 0.0.0.0")
|
||||||
|
command_fixed = True
|
||||||
|
|
||||||
|
if not command_fixed:
|
||||||
|
return
|
||||||
|
|
||||||
|
updated_compose = "\n".join(new_lines)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET compose_template = :compose_template
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"compose_template": updated_compose, "id": tool_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Updated code-server tool type ({tool_id}) to bind to 0.0.0.0")
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_jupyter_compose(conn) -> None:
|
||||||
|
"""Update jupyter-notebook compose template to bind to 0.0.0.0."""
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_template, definition_type
|
||||||
|
FROM tool_types
|
||||||
|
WHERE name = 'jupyter-notebook'
|
||||||
|
""")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
tool_id, compose_template, definition_type = result
|
||||||
|
|
||||||
|
if definition_type != "compose" or not compose_template:
|
||||||
|
return
|
||||||
|
|
||||||
|
if "command:" in compose_template:
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = compose_template.split("\n")
|
||||||
|
new_lines = []
|
||||||
|
image_line_idx = -1
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
new_lines.append(line)
|
||||||
|
if "image:" in line and image_line_idx == -1:
|
||||||
|
image_line_idx = i
|
||||||
|
indent = line[: len(line) - len(line.lstrip())]
|
||||||
|
# Jupyter needs --ip=0.0.0.0 to bind to all interfaces
|
||||||
|
new_lines.append(
|
||||||
|
f"{indent}command: start-notebook.sh --ip=0.0.0.0 --port=8888 --no-browser"
|
||||||
|
)
|
||||||
|
|
||||||
|
if image_line_idx == -1:
|
||||||
|
return
|
||||||
|
|
||||||
|
updated_compose = "\n".join(new_lines)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET compose_template = :compose_template
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"compose_template": updated_compose, "id": tool_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Updated jupyter-notebook tool type ({tool_id}) to bind to 0.0.0.0:8888")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
_fix_code_server_compose(conn)
|
||||||
|
_fix_jupyter_compose(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Cannot safely downgrade without knowing the original compose_template
|
||||||
|
pass
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Remove broken command override from LSIO code-server templates
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_remove_lsio_command_override
|
||||||
|
Revises: 2026_05_29_fix_code_server_bind_addr
|
||||||
|
Create Date: 2026-05-29 15:05:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_29_remove_lsio_command_override"
|
||||||
|
down_revision: str | None = "2026_05_29_fix_code_server_bind_addr"
|
||||||
|
branch_labels: Sequence[str] | None = None
|
||||||
|
depends_on: Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Fix tool_types templates in DB
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_template
|
||||||
|
FROM tool_types
|
||||||
|
WHERE name = 'code-server'
|
||||||
|
""")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
for tool_id, compose_template in result:
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(compose_template)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not data or "services" not in data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
modified = False
|
||||||
|
for svc in data["services"].values():
|
||||||
|
image = svc.get("image", "")
|
||||||
|
if not image or "linuxserver" not in image:
|
||||||
|
continue
|
||||||
|
if "command" in svc:
|
||||||
|
cmd = svc["command"]
|
||||||
|
if "--bind-addr" in cmd or "--host" in cmd:
|
||||||
|
del svc["command"]
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
if modified:
|
||||||
|
updated = yaml.dump(data, default_flow_style=False)
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET compose_template = :compose_template
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"compose_template": updated, "id": tool_id},
|
||||||
|
)
|
||||||
|
print(f"Removed broken command override from LSIO template ({tool_id})")
|
||||||
|
|
||||||
|
# Fix existing instance compose files on disk
|
||||||
|
# Use information_schema to check if compose_path column exists
|
||||||
|
col_result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'tool_instances'
|
||||||
|
AND column_name = 'compose_path'
|
||||||
|
""")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if col_result:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_path
|
||||||
|
FROM tool_instances
|
||||||
|
WHERE compose_path IS NOT NULL
|
||||||
|
""")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for instance_id, compose_path in result:
|
||||||
|
path = Path(compose_path)
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
content = path.read_text()
|
||||||
|
data = yaml.safe_load(content)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not data or "services" not in data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
modified = False
|
||||||
|
for svc in data["services"].values():
|
||||||
|
image = svc.get("image", "")
|
||||||
|
if not image or "linuxserver" not in image:
|
||||||
|
continue
|
||||||
|
if "command" in svc:
|
||||||
|
cmd = svc["command"]
|
||||||
|
if "--bind-addr" in cmd or "--host" in cmd:
|
||||||
|
del svc["command"]
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
if modified:
|
||||||
|
path.write_text(yaml.dump(data, default_flow_style=False))
|
||||||
|
print(
|
||||||
|
f"Removed broken command override from instance compose "
|
||||||
|
f"({instance_id})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""remove ssh_keys mount from pi-agent manifest
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_remove_ssh_keys_mount_from_manifest
|
||||||
|
Revises: 2026_05_29_add_ssh_key_ids_to_tool_instances
|
||||||
|
Create Date: 2026-05-29 14:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_29_remove_ssh_keys_mount_from_manifest"
|
||||||
|
down_revision: Union[str, None] = "2026_05_29_add_ssh_key_ids_to_tool_instances"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Remove the ssh_keys mount from the pi-agent manifest."""
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Get the pi-agent manifest
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT id, manifest FROM tool_definition_manifests WHERE name = 'pi-agent'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.fetchone()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
|
||||||
|
manifest_id, manifest_json = row
|
||||||
|
manifest = (
|
||||||
|
manifest_json if isinstance(manifest_json, dict) else json.loads(manifest_json)
|
||||||
|
)
|
||||||
|
|
||||||
|
mounts = manifest.get("mounts", [])
|
||||||
|
original_count = len(mounts)
|
||||||
|
|
||||||
|
# Remove any mount named "ssh_keys"
|
||||||
|
filtered_mounts = [m for m in mounts if m.get("name") != "ssh_keys"]
|
||||||
|
|
||||||
|
if len(filtered_mounts) < original_count:
|
||||||
|
manifest["mounts"] = filtered_mounts
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE tool_definition_manifests SET manifest = :manifest WHERE id = :id"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"manifest": json.dumps(manifest),
|
||||||
|
"id": manifest_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Restore the ssh_keys mount to the pi-agent manifest."""
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT id, manifest FROM tool_definition_manifests WHERE name = 'pi-agent'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.fetchone()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
|
||||||
|
manifest_id, manifest_json = row
|
||||||
|
manifest = (
|
||||||
|
manifest_json if isinstance(manifest_json, dict) else json.loads(manifest_json)
|
||||||
|
)
|
||||||
|
|
||||||
|
mounts = manifest.get("mounts", [])
|
||||||
|
|
||||||
|
# Check if ssh_keys mount already exists
|
||||||
|
if any(m.get("name") == "ssh_keys" for m in mounts):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add the ssh_keys mount back
|
||||||
|
mounts.append(
|
||||||
|
{
|
||||||
|
"name": "ssh_keys",
|
||||||
|
"target": "/home/user/.ssh",
|
||||||
|
"source_type": "ssh_key",
|
||||||
|
"mode": "0700",
|
||||||
|
"file_mode": "0600",
|
||||||
|
"readonly": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
manifest["mounts"] = mounts
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE tool_definition_manifests SET manifest = :manifest WHERE id = :id"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"manifest": json.dumps(manifest),
|
||||||
|
"id": manifest_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""add workspaces table
|
||||||
|
|
||||||
|
Revision ID: 2026_06_01_add_workspaces
|
||||||
|
Revises: 2026_05_29_fix_code_server_bind_addr_port
|
||||||
|
Create Date: 2026-06-01 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_06_01_add_workspaces"
|
||||||
|
down_revision: str | None = "2026_05_29_fix_code_server_bind_addr_port"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create workspaces table
|
||||||
|
op.create_table(
|
||||||
|
"workspaces",
|
||||||
|
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"repo_id",
|
||||||
|
sa.Uuid(as_uuid=True),
|
||||||
|
sa.ForeignKey("git_repositories.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Uuid(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("branch", sa.String(255), nullable=False, server_default="main"),
|
||||||
|
sa.Column("path", sa.String(2048), nullable=False),
|
||||||
|
sa.Column("status", sa.String(16), nullable=False, server_default="ready"),
|
||||||
|
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
|
||||||
|
if_not_exists=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index("idx_workspaces_repo_id", "workspaces", ["repo_id"])
|
||||||
|
op.create_index("idx_workspaces_user_id", "workspaces", ["user_id"])
|
||||||
|
op.create_index("idx_workspaces_status", "workspaces", ["status"])
|
||||||
|
|
||||||
|
# Add workspace_id to tool_instances
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column(
|
||||||
|
"workspace_id",
|
||||||
|
sa.Uuid(as_uuid=True),
|
||||||
|
sa.ForeignKey("workspaces.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_tool_instances_workspace_id", "tool_instances", ["workspace_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_tool_instances_workspace_id", table_name="tool_instances")
|
||||||
|
op.drop_column("tool_instances", "workspace_id")
|
||||||
|
op.drop_table("workspaces")
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
from src.api.auth import router as auth_router
|
from src.api.auth import router as auth_router
|
||||||
|
from src.api.events import router as events_router
|
||||||
|
from src.api.notifications import router as notifications_router
|
||||||
from src.api.users import router as users_router
|
from src.api.users import router as users_router
|
||||||
|
|
||||||
__all__ = ["auth_router", "users_router"]
|
__all__ = ["auth_router", "events_router", "notifications_router", "users_router"]
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""Config profile API endpoints."""
|
"""Config profile API endpoints."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -20,6 +23,7 @@ from src.services.config_profile_resolver import (
|
|||||||
resolve_profile,
|
resolve_profile,
|
||||||
resolved_profile_to_dict,
|
resolved_profile_to_dict,
|
||||||
)
|
)
|
||||||
|
from src.utils.git_url_parser import parse_git_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -56,18 +60,11 @@ def _calculate_profile_size(data: dict) -> int:
|
|||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
class GitMountItem(BaseModel):
|
class GitMountMapping(BaseModel):
|
||||||
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
|
source_path: str = Field(
|
||||||
source_path: str = Field(default=".", description="Path within repository (supports glob patterns)")
|
description="Path within repository (supports glob patterns)"
|
||||||
|
)
|
||||||
target_path: str = Field(description="Absolute path inside container")
|
target_path: str = Field(description="Absolute path inside container")
|
||||||
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
|
||||||
|
|
||||||
@field_validator("remote_url")
|
|
||||||
@classmethod
|
|
||||||
def validate_remote_url(cls, v: str) -> str:
|
|
||||||
if not v.startswith(("http://", "https://", "git@", "ssh://")):
|
|
||||||
raise ValueError("remote_url must be a valid git URL (https://, git@, or ssh://)")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("source_path")
|
@field_validator("source_path")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -86,10 +83,66 @@ class GitMountItem(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class GitMountItem(BaseModel):
|
||||||
|
remote_url: str = Field(description="Git remote URL (HTTPS or SSH)")
|
||||||
|
source_path: str | None = Field(
|
||||||
|
default=None, description="Path within repository (legacy single mapping)"
|
||||||
|
)
|
||||||
|
target_path: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Absolute path inside container (legacy single mapping)",
|
||||||
|
)
|
||||||
|
branch: str | None = Field(default=None, description="Optional branch or tag name")
|
||||||
|
mappings: list[GitMountMapping] | None = Field(
|
||||||
|
default=None, description="Multiple source/target mappings from the same repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("remote_url")
|
||||||
|
@classmethod
|
||||||
|
def validate_remote_url(cls, v: str) -> str:
|
||||||
|
if not v.startswith(("http://", "https://", "git@", "ssh://")):
|
||||||
|
raise ValueError(
|
||||||
|
"remote_url must be a valid git URL (https://, git@, or ssh://)"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("source_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_source_path(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if v.startswith("/"):
|
||||||
|
raise ValueError("source_path must be relative (no leading /)")
|
||||||
|
if ".." in v:
|
||||||
|
raise ValueError("source_path cannot contain path traversal (..)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("target_path")
|
||||||
|
@classmethod
|
||||||
|
def validate_target_path(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if ".." in v:
|
||||||
|
raise ValueError("target_path cannot contain path traversal (..)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def check_mappings_or_legacy(self):
|
||||||
|
has_legacy = self.source_path is not None and self.target_path is not None
|
||||||
|
has_mappings = self.mappings is not None and len(self.mappings) > 0
|
||||||
|
if not has_legacy and not has_mappings:
|
||||||
|
raise ValueError(
|
||||||
|
"Git mount must have either 'mappings' (non-empty array) or both 'source_path' and 'target_path'"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class MountItem(BaseModel):
|
class MountItem(BaseModel):
|
||||||
target: str = Field(description="Absolute mount target path")
|
target: str = Field(description="Absolute mount target path")
|
||||||
mode: str = Field(default="rw", description="Mount mode: ro or rw")
|
mode: str = Field(default="rw", description="Mount mode: ro or rw")
|
||||||
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
|
files: dict = Field(
|
||||||
|
default_factory=dict, description="Files as {relative_path: content}"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("target")
|
@field_validator("target")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -126,10 +179,18 @@ class ConfigProfileCreate(BaseModel):
|
|||||||
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
||||||
env_vars: dict = Field(default_factory=dict, description="Environment variables")
|
env_vars: dict = Field(default_factory=dict, description="Environment variables")
|
||||||
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
|
runtime_hints: dict = Field(default_factory=dict, description="Runtime hints")
|
||||||
mounts: list[MountItem] = Field(default_factory=list, description="Mount definitions")
|
mounts: list[MountItem] = Field(
|
||||||
files: dict = Field(default_factory=dict, description="Files as {relative_path: content}")
|
default_factory=list, description="Mount definitions"
|
||||||
git_mounts: list[GitMountItem] = Field(default_factory=list, description="Git repository mounts")
|
)
|
||||||
is_default: bool = Field(default=False, description="Whether this is the default profile for its scope")
|
files: dict = Field(
|
||||||
|
default_factory=dict, description="Files as {relative_path: content}"
|
||||||
|
)
|
||||||
|
git_mounts: list[GitMountItem] = Field(
|
||||||
|
default_factory=list, description="Git repository mounts"
|
||||||
|
)
|
||||||
|
is_default: bool = Field(
|
||||||
|
default=False, description="Whether this is the default profile for its scope"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("project_id", "tool_type_id")
|
@field_validator("project_id", "tool_type_id")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -179,10 +240,18 @@ class ConfigProfileUpdate(BaseModel):
|
|||||||
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
tool_type_id: str | None = Field(default=None, description="Optional tool type ID")
|
||||||
env_vars: dict | None = Field(default=None, description="Environment variables")
|
env_vars: dict | None = Field(default=None, description="Environment variables")
|
||||||
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
|
runtime_hints: dict | None = Field(default=None, description="Runtime hints")
|
||||||
mounts: list[MountItem] | None = Field(default=None, description="Mount definitions")
|
mounts: list[MountItem] | None = Field(
|
||||||
files: dict | None = Field(default=None, description="Files as {relative_path: content}")
|
default=None, description="Mount definitions"
|
||||||
git_mounts: list[GitMountItem] | None = Field(default=None, description="Git repository mounts")
|
)
|
||||||
is_default: bool | None = Field(default=None, description="Whether this is the default profile")
|
files: dict | None = Field(
|
||||||
|
default=None, description="Files as {relative_path: content}"
|
||||||
|
)
|
||||||
|
git_mounts: list[GitMountItem] | None = Field(
|
||||||
|
default=None, description="Git repository mounts"
|
||||||
|
)
|
||||||
|
is_default: bool | None = Field(
|
||||||
|
default=None, description="Whether this is the default profile"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("project_id", "tool_type_id")
|
@field_validator("project_id", "tool_type_id")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -232,7 +301,9 @@ class ConfigProfileResponse(BaseModel):
|
|||||||
updated_at: str
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
async def _get_profile_with_includes(session: AsyncSession, profile_id: uuid.UUID) -> ConfigProfile | None:
|
async def _get_profile_with_includes(
|
||||||
|
session: AsyncSession, profile_id: uuid.UUID
|
||||||
|
) -> ConfigProfile | None:
|
||||||
"""Fetch a profile with includes eagerly loaded."""
|
"""Fetch a profile with includes eagerly loaded."""
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ConfigProfile)
|
select(ConfigProfile)
|
||||||
@@ -252,18 +323,22 @@ async def _check_access(
|
|||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
project = await session.get(Project, project_id)
|
project = await session.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
|
||||||
|
)
|
||||||
# Add ownership check if needed; for now just verify existence
|
# Add ownership check if needed; for now just verify existence
|
||||||
if tool_type_id is not None:
|
if tool_type_id is not None:
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _validate_git_mounts(
|
async def _validate_git_mounts(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
git_mounts: list[dict],
|
git_mounts: list[Any],
|
||||||
project_id: uuid.UUID | None = None,
|
project_id: uuid.UUID | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Validate git mount URLs.
|
"""Validate git mount URLs.
|
||||||
@@ -286,7 +361,9 @@ async def _validate_git_mounts(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None) -> dict:
|
def _profile_to_response(
|
||||||
|
profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None
|
||||||
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(profile.id),
|
"id": str(profile.id),
|
||||||
"user_id": str(profile.user_id),
|
"user_id": str(profile.user_id),
|
||||||
@@ -316,13 +393,19 @@ def _profile_to_response(profile: ConfigProfile, includes: list[ConfigProfileInc
|
|||||||
@router.get("", response_model=list[ConfigProfileResponse])
|
@router.get("", response_model=list[ConfigProfileResponse])
|
||||||
async def list_config_profiles(
|
async def list_config_profiles(
|
||||||
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
project_id: str | None = Query(None, description="Filter by project compatibility"),
|
||||||
tool_type_id: str | None = Query(None, description="Filter by tool type compatibility"),
|
tool_type_id: str | None = Query(
|
||||||
|
None, description="Filter by tool type compatibility"
|
||||||
|
),
|
||||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
"""List config profiles, optionally filtered by compatibility."""
|
"""List config profiles, optionally filtered by compatibility."""
|
||||||
user_uuid = current_user_id
|
user_uuid = current_user_id
|
||||||
query = select(ConfigProfile).where(ConfigProfile.user_id == user_uuid).options(selectinload(ConfigProfile.includes))
|
query = (
|
||||||
|
select(ConfigProfile)
|
||||||
|
.where(ConfigProfile.user_id == user_uuid)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
|
)
|
||||||
|
|
||||||
if project_id or tool_type_id:
|
if project_id or tool_type_id:
|
||||||
# Compatibility filter: include portable profiles and matching scoped profiles
|
# Compatibility filter: include portable profiles and matching scoped profiles
|
||||||
@@ -334,7 +417,8 @@ async def list_config_profiles(
|
|||||||
conditions: list = []
|
conditions: list = []
|
||||||
# Portable profiles (no project, no tool)
|
# Portable profiles (no project, no tool)
|
||||||
conditions.append(
|
conditions.append(
|
||||||
(ConfigProfile.project_id.is_(None)) & (ConfigProfile.tool_type_id.is_(None))
|
(ConfigProfile.project_id.is_(None))
|
||||||
|
& (ConfigProfile.tool_type_id.is_(None))
|
||||||
)
|
)
|
||||||
if project_uuid:
|
if project_uuid:
|
||||||
# Profiles matching this project (with or without tool)
|
# Profiles matching this project (with or without tool)
|
||||||
@@ -345,7 +429,8 @@ async def list_config_profiles(
|
|||||||
if project_uuid and tool_uuid:
|
if project_uuid and tool_uuid:
|
||||||
# Exact match
|
# Exact match
|
||||||
conditions.append(
|
conditions.append(
|
||||||
(ConfigProfile.project_id == project_uuid) & (ConfigProfile.tool_type_id == tool_uuid)
|
(ConfigProfile.project_id == project_uuid)
|
||||||
|
& (ConfigProfile.tool_type_id == tool_uuid)
|
||||||
)
|
)
|
||||||
|
|
||||||
query = query.where(or_(*conditions))
|
query = query.where(or_(*conditions))
|
||||||
@@ -355,7 +440,9 @@ async def list_config_profiles(
|
|||||||
return [_profile_to_response(p) for p in profiles]
|
return [_profile_to_response(p) for p in profiles]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED)
|
@router.post(
|
||||||
|
"", response_model=ConfigProfileResponse, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
async def create_config_profile(
|
async def create_config_profile(
|
||||||
data: ConfigProfileCreate,
|
data: ConfigProfileCreate,
|
||||||
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
@@ -366,10 +453,12 @@ async def create_config_profile(
|
|||||||
|
|
||||||
# Check for duplicate name
|
# Check for duplicate name
|
||||||
existing = await session.execute(
|
existing = await session.execute(
|
||||||
select(ConfigProfile).where(
|
select(ConfigProfile)
|
||||||
|
.where(
|
||||||
ConfigProfile.user_id == user_uuid,
|
ConfigProfile.user_id == user_uuid,
|
||||||
ConfigProfile.name == data.name,
|
ConfigProfile.name == data.name,
|
||||||
).options(selectinload(ConfigProfile.includes))
|
)
|
||||||
|
.options(selectinload(ConfigProfile.includes))
|
||||||
)
|
)
|
||||||
if existing.scalar_one_or_none() is not None:
|
if existing.scalar_one_or_none() is not None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -384,7 +473,9 @@ async def create_config_profile(
|
|||||||
|
|
||||||
# Validate git mounts reference existing repositories
|
# Validate git mounts reference existing repositories
|
||||||
if data.git_mounts:
|
if data.git_mounts:
|
||||||
git_mounts_data = [m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts]
|
git_mounts_data = [
|
||||||
|
m.model_dump() if hasattr(m, "model_dump") else m for m in data.git_mounts
|
||||||
|
]
|
||||||
await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
|
await _validate_git_mounts(session, user_uuid, git_mounts_data, project_uuid)
|
||||||
|
|
||||||
# Check size
|
# Check size
|
||||||
@@ -432,9 +523,13 @@ async def get_config_profile(
|
|||||||
"""Get a config profile by ID."""
|
"""Get a config profile by ID."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
if profile.user_id != current_user_id:
|
if profile.user_id != current_user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
return _profile_to_response(profile)
|
return _profile_to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@@ -448,9 +543,13 @@ async def update_config_profile(
|
|||||||
"""Update a config profile."""
|
"""Update a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
if profile.user_id != current_user_id:
|
if profile.user_id != current_user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
@@ -488,7 +587,9 @@ async def update_config_profile(
|
|||||||
m.model_dump() if hasattr(m, "model_dump") else m
|
m.model_dump() if hasattr(m, "model_dump") else m
|
||||||
for m in update_data["git_mounts"]
|
for m in update_data["git_mounts"]
|
||||||
]
|
]
|
||||||
await _validate_git_mounts(session, profile.user_id, git_mounts_data, project_uuid)
|
await _validate_git_mounts(
|
||||||
|
session, profile.user_id, git_mounts_data, project_uuid
|
||||||
|
)
|
||||||
|
|
||||||
# Check size
|
# Check size
|
||||||
current_data = _profile_to_response(profile)
|
current_data = _profile_to_response(profile)
|
||||||
@@ -533,9 +634,13 @@ async def delete_config_profile(
|
|||||||
"""Delete a config profile."""
|
"""Delete a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
if profile.user_id != current_user_id:
|
if profile.user_id != current_user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
await session.delete(profile)
|
await session.delete(profile)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -554,9 +659,13 @@ async def update_profile_includes(
|
|||||||
"""Update the ordered includes for a config profile."""
|
"""Update the ordered includes for a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
if profile.user_id != current_user_id:
|
if profile.user_id != current_user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
# Validate all included profiles exist and belong to the user
|
# Validate all included profiles exist and belong to the user
|
||||||
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
included_uuids = [uuid.UUID(inc_id) for inc_id in data.includes]
|
||||||
@@ -596,7 +705,9 @@ async def update_profile_includes(
|
|||||||
|
|
||||||
# Remove existing includes
|
# Remove existing includes
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
|
select(ConfigProfileInclude).where(
|
||||||
|
ConfigProfileInclude.profile_id == profile.id
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for existing in result.scalars().all():
|
for existing in result.scalars().all():
|
||||||
await session.delete(existing)
|
await session.delete(existing)
|
||||||
@@ -621,7 +732,9 @@ async def update_profile_includes(
|
|||||||
profile = result.scalar_one()
|
profile = result.scalar_one()
|
||||||
|
|
||||||
inc_result = await session.execute(
|
inc_result = await session.execute(
|
||||||
select(ConfigProfileInclude).where(ConfigProfileInclude.profile_id == profile.id)
|
select(ConfigProfileInclude).where(
|
||||||
|
ConfigProfileInclude.profile_id == profile.id
|
||||||
|
)
|
||||||
)
|
)
|
||||||
direct_includes = inc_result.scalars().all()
|
direct_includes = inc_result.scalars().all()
|
||||||
|
|
||||||
@@ -638,9 +751,13 @@ async def preview_config_profile(
|
|||||||
"""Preview the resolved output of a config profile."""
|
"""Preview the resolved output of a config profile."""
|
||||||
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
profile = await _get_profile_with_includes(session, uuid.UUID(profile_id))
|
||||||
if profile is None:
|
if profile is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Profile not found"
|
||||||
|
)
|
||||||
if profile.user_id != current_user_id:
|
if profile.user_id != current_user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resolved = await resolve_profile(session, profile.id)
|
resolved = await resolve_profile(session, profile.id)
|
||||||
@@ -721,3 +838,171 @@ async def resolve_default_profile(
|
|||||||
# Fall back to first created compatible profile
|
# Fall back to first created compatible profile
|
||||||
first = profiles[0]
|
first = profiles[0]
|
||||||
return {"profile_id": str(first.id), "profile_name": first.name}
|
return {"profile_id": str(first.id), "profile_name": first.name}
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateGitUrlRequest(BaseModel):
|
||||||
|
url: str = Field(description="Git remote URL to validate")
|
||||||
|
ssh_key_id: str | None = Field(
|
||||||
|
default=None, description="Optional SSH key ID for private repos"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateGitUrlResponse(BaseModel):
|
||||||
|
valid: bool
|
||||||
|
suggested_url: str | None = None
|
||||||
|
branches: list[str] | None = None
|
||||||
|
default_branch: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
error_code: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/validate-git-url", response_model=ValidateGitUrlResponse)
|
||||||
|
async def validate_git_url(
|
||||||
|
data: ValidateGitUrlRequest,
|
||||||
|
current_user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> ValidateGitUrlResponse:
|
||||||
|
"""Validate a git remote URL and list available branches.
|
||||||
|
|
||||||
|
Parses the URL, suggests corrections for browser URLs, and runs
|
||||||
|
git ls-remote to verify reachability and enumerate branches.
|
||||||
|
"""
|
||||||
|
parse_result = parse_git_url(data.url)
|
||||||
|
original_url = data.url.strip()
|
||||||
|
url_to_check = parse_result.get("base_url") or original_url
|
||||||
|
|
||||||
|
if not url_to_check:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error=parse_result.get("message", "Invalid URL"),
|
||||||
|
error_code=parse_result.get("error_code", "INVALID_URL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# If the URL needed parsing, return suggestion without checking remote
|
||||||
|
if parse_result.get("needs_parsing") and url_to_check != original_url:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
suggested_url=url_to_check,
|
||||||
|
error=parse_result.get("message"),
|
||||||
|
error_code=parse_result.get("error_code", "URL_NEEDS_PARSING"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional SSH key for private repos
|
||||||
|
env = None
|
||||||
|
key_path = None
|
||||||
|
if data.ssh_key_id:
|
||||||
|
from src.models.ssh_key import SSHKey
|
||||||
|
from src.services.ssh_keys import _get_fernet
|
||||||
|
|
||||||
|
try:
|
||||||
|
ssh_key_uuid = uuid.UUID(data.ssh_key_id)
|
||||||
|
except ValueError:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="Invalid SSH key ID format",
|
||||||
|
error_code="INVALID_SSH_KEY",
|
||||||
|
)
|
||||||
|
|
||||||
|
ssh_key = await session.get(SSHKey, ssh_key_uuid)
|
||||||
|
if ssh_key is None or ssh_key.user_id != current_user_id:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="SSH key not found or not authorized",
|
||||||
|
error_code="SSH_KEY_NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
fernet = _get_fernet()
|
||||||
|
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
||||||
|
try:
|
||||||
|
os.write(fd, private_key.encode())
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
env = {
|
||||||
|
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "ls-remote", "--heads", url_to_check],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="Remote repository check timed out",
|
||||||
|
error_code="TIMEOUT",
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="git command not found on server",
|
||||||
|
error_code="GIT_NOT_FOUND",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
stderr = result.stderr.strip()
|
||||||
|
if (
|
||||||
|
"could not resolve" in stderr.lower()
|
||||||
|
or "unable to access" in stderr.lower()
|
||||||
|
):
|
||||||
|
error_msg = "Could not reach repository. Check the URL and network access."
|
||||||
|
error_code = "UNREACHABLE"
|
||||||
|
elif (
|
||||||
|
"authentication" in stderr.lower() or "permission denied" in stderr.lower()
|
||||||
|
):
|
||||||
|
error_msg = (
|
||||||
|
"Authentication failed. Provide an SSH key for private repositories."
|
||||||
|
)
|
||||||
|
error_code = "AUTH_FAILED"
|
||||||
|
else:
|
||||||
|
error_msg = f"Repository not accessible: {stderr[:200]}"
|
||||||
|
error_code = "REMOTE_ERROR"
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error=error_msg,
|
||||||
|
error_code=error_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse branches from ls-remote output
|
||||||
|
branches: list[str] = []
|
||||||
|
default_branch = "main"
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) == 2:
|
||||||
|
ref = parts[1]
|
||||||
|
# refs/heads/branch-name
|
||||||
|
if ref.startswith("refs/heads/"):
|
||||||
|
branch_name = ref[len("refs/heads/") :]
|
||||||
|
branches.append(branch_name)
|
||||||
|
if branch_name in ("main", "master"):
|
||||||
|
default_branch = branch_name
|
||||||
|
|
||||||
|
if not branches:
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=False,
|
||||||
|
error="No branches found in remote repository",
|
||||||
|
error_code="NO_BRANCHES",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ValidateGitUrlResponse(
|
||||||
|
valid=True,
|
||||||
|
suggested_url=url_to_check if url_to_check != original_url else None,
|
||||||
|
branches=branches,
|
||||||
|
default_branch=default_branch,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""SSE streaming endpoint for instance events."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id
|
||||||
|
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/events", tags=["events"])
|
||||||
|
|
||||||
|
# In-memory connection counter per user (single-process assumption)
|
||||||
|
_connection_counts: dict[uuid.UUID, int] = {}
|
||||||
|
MAX_CONNECTIONS_PER_USER = 20
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stream")
|
||||||
|
async def events_stream(
|
||||||
|
request: Request,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""Stream instance events via Server-Sent Events.
|
||||||
|
|
||||||
|
Enforces a maximum of 5 concurrent connections per user.
|
||||||
|
"""
|
||||||
|
current = _connection_counts.get(user_id, 0)
|
||||||
|
if current >= MAX_CONNECTIONS_PER_USER:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Too many SSE connections",
|
||||||
|
)
|
||||||
|
|
||||||
|
_connection_counts[user_id] = current + 1
|
||||||
|
|
||||||
|
async def event_generator() -> AsyncGenerator[str, None]:
|
||||||
|
event_bus = InstanceEventBus()
|
||||||
|
queue: asyncio.Queue[InstanceEventPayload] = asyncio.Queue(maxsize=100)
|
||||||
|
|
||||||
|
async def on_event(payload: InstanceEventPayload) -> None:
|
||||||
|
try:
|
||||||
|
queue.put_nowait(payload)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
# Drop oldest event to make room
|
||||||
|
with contextlib.suppress(asyncio.QueueEmpty):
|
||||||
|
queue.get_nowait()
|
||||||
|
with contextlib.suppress(asyncio.QueueFull):
|
||||||
|
queue.put_nowait(payload)
|
||||||
|
|
||||||
|
unsubscribe = event_bus.subscribe("*", on_event)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
payload = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||||
|
yield f"event: {payload['event']}\ndata: {json.dumps(payload)}\n\n"
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
yield ":ping\n\n"
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Client disconnected
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
unsubscribe()
|
||||||
|
_connection_counts[user_id] = max(0, _connection_counts.get(user_id, 1) - 1)
|
||||||
|
if _connection_counts[user_id] == 0:
|
||||||
|
_connection_counts.pop(user_id, None)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -10,7 +10,12 @@ from pydantic import BaseModel, ConfigDict
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
|
from src.auth.dependencies import (
|
||||||
|
_get_owned_project,
|
||||||
|
_get_user,
|
||||||
|
get_current_user_id,
|
||||||
|
get_db_session,
|
||||||
|
)
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
@@ -90,7 +95,9 @@ def _prepare_ssh_env(ssh_key: SSHKey | None) -> dict | None:
|
|||||||
return env, key_path
|
return env, key_path
|
||||||
|
|
||||||
|
|
||||||
def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None) -> None:
|
def _preflight_remote_repository(
|
||||||
|
remote_url: str, ssh_key: SSHKey | None = None
|
||||||
|
) -> None:
|
||||||
"""Verify a remote repository is reachable before cloning."""
|
"""Verify a remote repository is reachable before cloning."""
|
||||||
env = None
|
env = None
|
||||||
key_path = None
|
key_path = None
|
||||||
@@ -109,22 +116,32 @@ def _preflight_remote_repository(remote_url: str, ssh_key: SSHKey | None = None)
|
|||||||
env={**os.environ, **env} if env else None,
|
env={**os.environ, **env} if env else None,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="remote repository check timed out",
|
||||||
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="git command not found",
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
if key_path and os.path.exists(key_path):
|
if key_path and os.path.exists(key_path):
|
||||||
os.unlink(key_path)
|
os.unlink(key_path)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
logger.error("Preflight check failed for %s: stderr=%s", remote_url, result.stderr)
|
logger.error(
|
||||||
|
"Preflight check failed for %s: stderr=%s", remote_url, result.stderr
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"repository not found or inaccessible: {result.stderr}",
|
detail=f"repository not found or inaccessible: {result.stderr}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey | None = None) -> None:
|
def _clone_working_repository(
|
||||||
|
remote_url: str, repo_path: str, ssh_key: SSHKey | None = None
|
||||||
|
) -> None:
|
||||||
env = None
|
env = None
|
||||||
key_path = None
|
key_path = None
|
||||||
|
|
||||||
@@ -142,9 +159,14 @@ def _clone_working_repository(remote_url: str, repo_path: str, ssh_key: SSHKey |
|
|||||||
env={**os.environ, **env} if env else None,
|
env={**os.environ, **env} if env else None,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out"
|
||||||
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="git command not found",
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
if key_path and os.path.exists(key_path):
|
if key_path and os.path.exists(key_path):
|
||||||
os.unlink(key_path)
|
os.unlink(key_path)
|
||||||
@@ -165,7 +187,10 @@ def _init_working_repository(repo_path: str) -> None:
|
|||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="git command not found",
|
||||||
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
return
|
return
|
||||||
@@ -310,7 +335,10 @@ async def create_external_repository(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="repository name already exists",
|
||||||
|
)
|
||||||
|
|
||||||
# Validate and potentially correct the URL
|
# Validate and potentially correct the URL
|
||||||
remote_url = data.remote_url
|
remote_url = data.remote_url
|
||||||
@@ -336,13 +364,21 @@ async def create_external_repository(
|
|||||||
try:
|
try:
|
||||||
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="invalid ssh_key_id format",
|
||||||
|
)
|
||||||
|
|
||||||
ssh_key = await session.get(SSHKey, ssh_key_id)
|
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||||
if ssh_key is None:
|
if ssh_key is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||||
|
)
|
||||||
if ssh_key.user_id != user_id:
|
if ssh_key.user_id != user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="ssh key does not belong to user",
|
||||||
|
)
|
||||||
|
|
||||||
if remote_url:
|
if remote_url:
|
||||||
_preflight_remote_repository(remote_url, ssh_key)
|
_preflight_remote_repository(remote_url, ssh_key)
|
||||||
@@ -369,7 +405,10 @@ async def create_external_repository(
|
|||||||
repo.is_mirror = False
|
repo.is_mirror = False
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to clone repository: {exc}")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to clone repository: {exc}",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Initialize empty repo
|
# Initialize empty repo
|
||||||
os.makedirs(repo_path, exist_ok=True)
|
os.makedirs(repo_path, exist_ok=True)
|
||||||
@@ -438,7 +477,9 @@ async def delete_repository(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
# Remove from disk
|
# Remove from disk
|
||||||
if os.path.exists(repo.path):
|
if os.path.exists(repo.path):
|
||||||
@@ -484,7 +525,10 @@ async def create_repository(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="repository name already exists",
|
||||||
|
)
|
||||||
|
|
||||||
# Validate and potentially correct the URL
|
# Validate and potentially correct the URL
|
||||||
remote_url = data.remote_url
|
remote_url = data.remote_url
|
||||||
@@ -511,13 +555,21 @@ async def create_repository(
|
|||||||
try:
|
try:
|
||||||
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="invalid ssh_key_id format",
|
||||||
|
)
|
||||||
|
|
||||||
ssh_key = await session.get(SSHKey, ssh_key_id)
|
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||||
if ssh_key is None:
|
if ssh_key is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||||
|
)
|
||||||
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
|
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="ssh key does not belong to user or project",
|
||||||
|
)
|
||||||
|
|
||||||
if remote_url:
|
if remote_url:
|
||||||
_preflight_remote_repository(remote_url, ssh_key)
|
_preflight_remote_repository(remote_url, ssh_key)
|
||||||
@@ -581,20 +633,30 @@ async def update_repository_ssh_key(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
# Validate SSH key if provided
|
# Validate SSH key if provided
|
||||||
if data.ssh_key_id:
|
if data.ssh_key_id:
|
||||||
try:
|
try:
|
||||||
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
ssh_key_id = uuid.UUID(data.ssh_key_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid ssh_key_id format")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="invalid ssh_key_id format",
|
||||||
|
)
|
||||||
|
|
||||||
ssh_key = await session.get(SSHKey, ssh_key_id)
|
ssh_key = await session.get(SSHKey, ssh_key_id)
|
||||||
if ssh_key is None:
|
if ssh_key is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="ssh key not found"
|
||||||
|
)
|
||||||
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
|
if ssh_key.user_id != user_id and ssh_key.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ssh key does not belong to user or project")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="ssh key does not belong to user or project",
|
||||||
|
)
|
||||||
|
|
||||||
repo.ssh_key_id = ssh_key_id
|
repo.ssh_key_id = ssh_key_id
|
||||||
else:
|
else:
|
||||||
@@ -640,16 +702,24 @@ async def get_repository_history(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(repo.path):
|
if not os.path.exists(repo.path):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
history = get_commit_history(repo.path, branch=branch, limit=limit, offset=offset)
|
history = get_commit_history(
|
||||||
|
repo.path, branch=branch, limit=limit, offset=offset
|
||||||
|
)
|
||||||
return history
|
return history
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -681,10 +751,14 @@ async def get_repository_commit(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(repo.path):
|
if not os.path.exists(repo.path):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
detail = get_commit_detail(repo.path, commit_hash)
|
detail = get_commit_detail(repo.path, commit_hash)
|
||||||
@@ -763,10 +837,14 @@ async def list_repository_files(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(repo.path):
|
if not os.path.exists(repo.path):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
entries = list_tree(repo.path, branch=branch, path=path)
|
entries = list_tree(repo.path, branch=branch, path=path)
|
||||||
@@ -829,10 +907,14 @@ async def get_repository_file_content(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(repo.path):
|
if not os.path.exists(repo.path):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file_content = get_file_content(repo.path, branch=branch, path=path)
|
file_content = get_file_content(repo.path, branch=branch, path=path)
|
||||||
@@ -847,7 +929,9 @@ async def get_repository_file_content(
|
|||||||
last_commit=file_content.last_commit,
|
last_commit=file_content.last_commit,
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="file not found"
|
||||||
|
)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
@@ -880,11 +964,16 @@ async def get_repository_branches(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(repo.path):
|
# Try local repo first (.git subdir for normal repos, HEAD for bare)
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
is_valid_git_repo = os.path.isdir(
|
||||||
|
os.path.join(repo.path, ".git")
|
||||||
|
) or os.path.isfile(os.path.join(repo.path, "HEAD"))
|
||||||
|
|
||||||
|
if is_valid_git_repo:
|
||||||
try:
|
try:
|
||||||
branches, default_branch = list_branches(repo.path)
|
branches, default_branch = list_branches(repo.path)
|
||||||
return BranchesResponse(
|
return BranchesResponse(
|
||||||
@@ -905,7 +994,74 @@ async def get_repository_branches(
|
|||||||
str(e),
|
str(e),
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
|
||||||
|
) from e
|
||||||
|
|
||||||
|
# Local repo missing/corrupt — try remote if available
|
||||||
|
if repo.remote_url:
|
||||||
|
ssh_key = None
|
||||||
|
if repo.ssh_key_id:
|
||||||
|
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
|
||||||
|
|
||||||
|
ssh_result = _prepare_ssh_env(ssh_key)
|
||||||
|
env = None
|
||||||
|
key_path = None
|
||||||
|
if ssh_result:
|
||||||
|
env, key_path = ssh_result
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "ls-remote", "--heads", repo.remote_url],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
remote_branches = []
|
||||||
|
default_branch = "main"
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
if line:
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) == 2:
|
||||||
|
ref = parts[1]
|
||||||
|
if ref.startswith("refs/heads/"):
|
||||||
|
branch_name = ref[len("refs/heads/") :]
|
||||||
|
remote_branches.append(branch_name)
|
||||||
|
if branch_name in ("main", "master"):
|
||||||
|
default_branch = branch_name
|
||||||
|
if remote_branches:
|
||||||
|
return BranchesResponse(
|
||||||
|
branches=[
|
||||||
|
{
|
||||||
|
"name": b,
|
||||||
|
"is_default": b == default_branch,
|
||||||
|
"last_commit": None,
|
||||||
|
}
|
||||||
|
for b in remote_branches
|
||||||
|
],
|
||||||
|
default_branch=default_branch,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"ls-remote returned %d for repo %s: %s",
|
||||||
|
result.returncode,
|
||||||
|
repo_id,
|
||||||
|
result.stderr,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("ls-remote timed out for repo %s", repo_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("ls-remote failed for repo %s: %s", repo_id, str(e))
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="repository not found on disk — re-clone or re-create the repository",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -938,10 +1094,14 @@ async def update_repository_file(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(repo.path):
|
if not os.path.exists(repo.path):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||||
|
)
|
||||||
|
|
||||||
# Get user info for commit
|
# Get user info for commit
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
@@ -1009,10 +1169,14 @@ async def get_repository_status(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(repo.path):
|
if not os.path.exists(repo.path):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
status_result = get_status(repo.path)
|
status_result = get_status(repo.path)
|
||||||
@@ -1068,10 +1232,14 @@ async def create_repository_branch(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(repo.path):
|
if not os.path.exists(repo.path):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
create_branch(repo.path, data.name, data.base_branch)
|
create_branch(repo.path, data.name, data.base_branch)
|
||||||
@@ -1111,10 +1279,14 @@ async def delete_repository_branch(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(repo.path):
|
if not os.path.exists(repo.path):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
delete_branch(repo.path, branch_name, force)
|
delete_branch(repo.path, branch_name, force)
|
||||||
@@ -1152,10 +1324,14 @@ async def checkout_repository_branch(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(repo.path):
|
if not os.path.exists(repo.path):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
checkout_branch(repo.path, data.branch)
|
checkout_branch(repo.path, data.branch)
|
||||||
@@ -1204,10 +1380,14 @@ async def commit_repository_changes(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(repo.path):
|
if not os.path.exists(repo.path):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||||
|
)
|
||||||
|
|
||||||
# Get user info for commit
|
# Get user info for commit
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
@@ -1262,10 +1442,14 @@ async def fetch_repository(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(repo.path):
|
if not os.path.exists(repo.path):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
fetch(repo.path)
|
fetch(repo.path)
|
||||||
@@ -1308,10 +1492,14 @@ async def pull_repository(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(repo.path):
|
if not os.path.exists(repo.path):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pull(repo.path, branch)
|
pull(repo.path, branch)
|
||||||
@@ -1354,10 +1542,14 @@ async def push_repository(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(repo.path):
|
if not os.path.exists(repo.path):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
push(repo.path, branch)
|
push(repo.path, branch)
|
||||||
@@ -1407,10 +1599,14 @@ async def merge_repository_branches(
|
|||||||
|
|
||||||
repo = await session.get(GitRepository, repo_id)
|
repo = await session.get(GitRepository, repo_id)
|
||||||
if repo is None or repo.project_id != project_id:
|
if repo is None or repo.project_id != project_id:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found"
|
||||||
|
)
|
||||||
|
|
||||||
if not os.path.exists(repo.path):
|
if not os.path.exists(repo.path):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
commit_hash = merge(
|
commit_hash = merge(
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Notification API endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user, get_db_session
|
||||||
|
from src.models.user import User
|
||||||
|
from src.models.user_config import UserConfig
|
||||||
|
from src.services.notification_service import notification_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationItem(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
category: str
|
||||||
|
severity: str
|
||||||
|
title: str
|
||||||
|
message: str | None
|
||||||
|
source_type: str | None
|
||||||
|
source_id: uuid.UUID | None
|
||||||
|
notification_metadata: dict = Field(serialization_alias="metadata")
|
||||||
|
read_at: datetime | None
|
||||||
|
dismissed_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationListResponse(BaseModel):
|
||||||
|
items: list[NotificationItem]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class UnreadCountResponse(BaseModel):
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class MarkAllReadResponse(BaseModel):
|
||||||
|
marked_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ClearAllResponse(BaseModel):
|
||||||
|
cleared_count: int
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_mute_categories(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Read notification mute categories from user config."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
if config is None:
|
||||||
|
return []
|
||||||
|
mute_categories = config.config.get("notification_mute_categories", [])
|
||||||
|
if isinstance(mute_categories, list):
|
||||||
|
return mute_categories
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=NotificationListResponse)
|
||||||
|
async def list_notifications(
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
unread_only: bool = Query(False),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> NotificationListResponse:
|
||||||
|
"""List notifications for the authenticated user."""
|
||||||
|
mute_categories = await _get_mute_categories(session, user.id)
|
||||||
|
items, total = await notification_service.list_notifications(
|
||||||
|
session,
|
||||||
|
user.id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
unread_only=unread_only,
|
||||||
|
mute_categories=mute_categories,
|
||||||
|
)
|
||||||
|
return NotificationListResponse(
|
||||||
|
items=[NotificationItem.model_validate(item) for item in items],
|
||||||
|
total=total,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unread", response_model=UnreadCountResponse)
|
||||||
|
async def get_unread_count(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> UnreadCountResponse:
|
||||||
|
"""Get unread notification count for the authenticated user."""
|
||||||
|
count = await notification_service.get_unread_count(session, user.id)
|
||||||
|
return UnreadCountResponse(count=count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{notification_id}/read", response_model=NotificationItem)
|
||||||
|
async def mark_notification_read(
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> NotificationItem:
|
||||||
|
"""Mark a single notification as read."""
|
||||||
|
try:
|
||||||
|
notification = await notification_service.mark_read(
|
||||||
|
session, notification_id, user.id
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Notification not found",
|
||||||
|
) from exc
|
||||||
|
return NotificationItem.model_validate(notification)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mark-all-read", response_model=MarkAllReadResponse)
|
||||||
|
async def mark_all_read(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> MarkAllReadResponse:
|
||||||
|
"""Mark all unread notifications as read."""
|
||||||
|
marked = await notification_service.mark_all_read(session, user.id)
|
||||||
|
return MarkAllReadResponse(marked_count=marked)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("", status_code=status.HTTP_200_OK)
|
||||||
|
async def clear_all_notifications(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> ClearAllResponse:
|
||||||
|
"""Dismiss all notifications for the authenticated user."""
|
||||||
|
cleared = await notification_service.dismiss_all(session, user.id)
|
||||||
|
return ClearAllResponse(cleared_count=cleared)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{notification_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def dismiss_notification(
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> None:
|
||||||
|
"""Soft-delete (dismiss) a single notification."""
|
||||||
|
try:
|
||||||
|
await notification_service.dismiss(session, notification_id, user.id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Notification not found",
|
||||||
|
) from exc
|
||||||
@@ -4,13 +4,19 @@ import uuid
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
|
from src.auth.dependencies import (
|
||||||
|
_get_owned_project,
|
||||||
|
_get_user,
|
||||||
|
get_current_user_id,
|
||||||
|
get_db_session,
|
||||||
|
)
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
|
||||||
@@ -76,26 +82,77 @@ async def create_project(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"",
|
"",
|
||||||
response_model=list[ProjectResponse],
|
|
||||||
summary="List all projects",
|
summary="List all projects",
|
||||||
description="Retrieve all projects owned by the authenticated user.",
|
description="Retrieve all projects owned by the authenticated user with repositories and workspaces.",
|
||||||
)
|
)
|
||||||
async def list_projects(
|
async def list_projects(
|
||||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
session: AsyncSession = Depends(get_db_session),
|
session: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[Project]:
|
) -> list[dict]:
|
||||||
"""List all projects for the authenticated user.
|
"""List all projects for the authenticated user.
|
||||||
|
|
||||||
Args:
|
Returns projects with nested repositories and workspaces for inline display.
|
||||||
user_id: ID of the authenticated user.
|
|
||||||
session: Database session.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of projects owned by the user.
|
|
||||||
"""
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
result = await session.execute(select(Project).where(Project.owner_id == user.id))
|
result = await session.execute(
|
||||||
return list(result.scalars().all())
|
select(Project)
|
||||||
|
.where(Project.owner_id == user.id)
|
||||||
|
.order_by(Project.created_at.desc())
|
||||||
|
)
|
||||||
|
projects = result.scalars().all()
|
||||||
|
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
|
||||||
|
enriched = []
|
||||||
|
for project in projects:
|
||||||
|
repos_result = await session.execute(
|
||||||
|
select(GitRepository).where(GitRepository.project_id == project.id)
|
||||||
|
)
|
||||||
|
repositories = []
|
||||||
|
for repo in repos_result.scalars().all():
|
||||||
|
ws_result = await session.execute(
|
||||||
|
select(Workspace).where(Workspace.repo_id == repo.id)
|
||||||
|
)
|
||||||
|
workspaces = []
|
||||||
|
for ws in ws_result.scalars().all():
|
||||||
|
# Count instances
|
||||||
|
inst_result = await session.execute(
|
||||||
|
select(func.count()).where(ToolInstance.workspace_id == ws.id)
|
||||||
|
)
|
||||||
|
instance_count = inst_result.scalar() or 0
|
||||||
|
workspaces.append(
|
||||||
|
{
|
||||||
|
"id": str(ws.id),
|
||||||
|
"name": ws.name,
|
||||||
|
"branch": ws.branch,
|
||||||
|
"status": ws.status,
|
||||||
|
"instance_count": instance_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
repositories.append(
|
||||||
|
{
|
||||||
|
"id": str(repo.id),
|
||||||
|
"name": repo.name,
|
||||||
|
"remote_url": repo.remote_url,
|
||||||
|
"workspaces": workspaces,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
enriched.append(
|
||||||
|
{
|
||||||
|
"id": str(project.id),
|
||||||
|
"name": project.name,
|
||||||
|
"description": project.description,
|
||||||
|
"owner_id": str(project.owner_id),
|
||||||
|
"repositories": repositories,
|
||||||
|
"created_at": project.created_at.isoformat()
|
||||||
|
if project.created_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -184,7 +241,9 @@ async def delete_project(
|
|||||||
project = await _get_owned_project(project_id, user_id, session)
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
# Delete repositories from disk and database
|
# Delete repositories from disk and database
|
||||||
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
|
result = await session.execute(
|
||||||
|
select(GitRepository).where(GitRepository.project_id == project_id)
|
||||||
|
)
|
||||||
repositories = result.scalars().all()
|
repositories = result.scalars().all()
|
||||||
for repo in repositories:
|
for repo in repositories:
|
||||||
if os.path.exists(repo.path):
|
if os.path.exists(repo.path):
|
||||||
|
|||||||
@@ -222,8 +222,7 @@ async def _handle_terminal_websocket(
|
|||||||
# Use mutable session reference so loops can survive reset
|
# Use mutable session reference so loops can survive reset
|
||||||
session_ref = SessionRef(session, slot_session_id)
|
session_ref = SessionRef(session, slot_session_id)
|
||||||
|
|
||||||
# Start I/O loops and heartbeat
|
# Start write loop and heartbeat (read is now event-driven in TerminalSession)
|
||||||
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
|
|
||||||
write_task = asyncio.create_task(
|
write_task = asyncio.create_task(
|
||||||
_write_loop(session_ref, websocket, instance_id)
|
_write_loop(session_ref, websocket, instance_id)
|
||||||
)
|
)
|
||||||
@@ -232,7 +231,7 @@ async def _handle_terminal_websocket(
|
|||||||
|
|
||||||
# Wait for either task to complete (indicating disconnect or error)
|
# Wait for either task to complete (indicating disconnect or error)
|
||||||
done, pending = await asyncio.wait(
|
done, pending = await asyncio.wait(
|
||||||
[read_task, write_task, heartbeat_task],
|
[write_task, heartbeat_task],
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -267,28 +266,6 @@ async def _handle_terminal_websocket(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _read_loop(session_ref: SessionRef, websocket) -> None:
|
|
||||||
"""Read output from the container and send to WebSocket."""
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
session = session_ref.session
|
|
||||||
if not session.is_alive() or session._closed:
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
continue
|
|
||||||
data = await session.read_output()
|
|
||||||
if data:
|
|
||||||
try:
|
|
||||||
await websocket.send_bytes(data)
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
|
||||||
"""Read input from WebSocket and send to container."""
|
"""Read input from WebSocket and send to container."""
|
||||||
try:
|
try:
|
||||||
@@ -319,6 +296,10 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
|||||||
rows,
|
rows,
|
||||||
)
|
)
|
||||||
await session.resize(cols, rows)
|
await session.resize(cols, rows)
|
||||||
|
elif msg_type == "ack":
|
||||||
|
char_count = ctrl.get("chars", 0)
|
||||||
|
if char_count > 0:
|
||||||
|
session.acknowledge_data(char_count)
|
||||||
elif msg_type == "reset":
|
elif msg_type == "reset":
|
||||||
# Reset terminal session (scoped to current slot)
|
# Reset terminal session (scoped to current slot)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
+890
-143
File diff suppressed because it is too large
Load Diff
@@ -50,7 +50,9 @@ class ToolTypeCreate(BaseModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def validate_definition_type(cls, v: str) -> str:
|
def validate_definition_type(cls, v: str) -> str:
|
||||||
if v not in ("compose", "dockerfile", "manifest"):
|
if v not in ("compose", "dockerfile", "manifest"):
|
||||||
raise ValueError("definition_type must be 'compose', 'dockerfile', or 'manifest'")
|
raise ValueError(
|
||||||
|
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
|
||||||
|
)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("compose_template")
|
@field_validator("compose_template")
|
||||||
@@ -61,7 +63,9 @@ class ToolTypeCreate(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
if v is None or not v.strip():
|
if v is None or not v.strip():
|
||||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
raise ValueError(
|
||||||
|
"compose_template is required when definition_type is 'compose'"
|
||||||
|
)
|
||||||
|
|
||||||
validate_compose_yaml(v)
|
validate_compose_yaml(v)
|
||||||
return v
|
return v
|
||||||
@@ -74,7 +78,9 @@ class ToolTypeCreate(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
if v is None or not v.strip():
|
if v is None or not v.strip():
|
||||||
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
raise ValueError(
|
||||||
|
"dockerfile_template is required when definition_type is 'dockerfile'"
|
||||||
|
)
|
||||||
|
|
||||||
if not v.strip().startswith("FROM"):
|
if not v.strip().startswith("FROM"):
|
||||||
raise ValueError("Dockerfile must start with a FROM instruction")
|
raise ValueError("Dockerfile must start with a FROM instruction")
|
||||||
@@ -116,7 +122,9 @@ class ToolTypeCreate(BaseModel):
|
|||||||
for var in v:
|
for var in v:
|
||||||
placeholder = f"{{{{{var}}}}}"
|
placeholder = f"{{{{{var}}}}}"
|
||||||
if placeholder not in template:
|
if placeholder not in template:
|
||||||
raise ValueError(f"Required variable '{var}' not found in compose template")
|
raise ValueError(
|
||||||
|
f"Required variable '{var}' not found in compose template"
|
||||||
|
)
|
||||||
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -124,23 +132,39 @@ class ToolTypeCreate(BaseModel):
|
|||||||
def validate_templates(self) -> "ToolTypeCreate":
|
def validate_templates(self) -> "ToolTypeCreate":
|
||||||
if self.definition_type == "manifest":
|
if self.definition_type == "manifest":
|
||||||
if self.manifest_id is None:
|
if self.manifest_id is None:
|
||||||
raise ValueError("manifest_id is required when definition_type is 'manifest'")
|
raise ValueError(
|
||||||
|
"manifest_id is required when definition_type is 'manifest'"
|
||||||
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
if self.definition_type == "dockerfile" and (self.dockerfile_template is None or not self.dockerfile_template.strip()):
|
if self.definition_type == "dockerfile" and (
|
||||||
raise ValueError("dockerfile_template is required when definition_type is 'dockerfile'")
|
self.dockerfile_template is None or not self.dockerfile_template.strip()
|
||||||
if self.definition_type == "compose" and (self.compose_template is None or not self.compose_template.strip()):
|
):
|
||||||
raise ValueError("compose_template is required when definition_type is 'compose'")
|
raise ValueError(
|
||||||
|
"dockerfile_template is required when definition_type is 'dockerfile'"
|
||||||
|
)
|
||||||
|
if self.definition_type == "compose" and (
|
||||||
|
self.compose_template is None or not self.compose_template.strip()
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"compose_template is required when definition_type is 'compose'"
|
||||||
|
)
|
||||||
|
|
||||||
# Validate that default_port is exposed in compose template (only if requires_port)
|
# Validate that default_port is exposed in compose template (only if requires_port)
|
||||||
if self.requires_port and self.definition_type == "compose" and self.compose_template:
|
if (
|
||||||
|
self.requires_port
|
||||||
|
and self.definition_type == "compose"
|
||||||
|
and self.compose_template
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
parsed = validate_compose_yaml(self.compose_template)
|
parsed = validate_compose_yaml(self.compose_template)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
if not check_port_exposed(parsed, self.default_port):
|
if not check_port_exposed(parsed, self.default_port):
|
||||||
raise ValueError(f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section.")
|
raise ValueError(
|
||||||
|
f"Port {self.default_port} is not exposed in the compose template. Add it to the 'ports' section."
|
||||||
|
)
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -167,7 +191,9 @@ class ToolTypeUpdate(BaseModel):
|
|||||||
if v is None:
|
if v is None:
|
||||||
return v
|
return v
|
||||||
if v not in ("compose", "dockerfile", "manifest"):
|
if v not in ("compose", "dockerfile", "manifest"):
|
||||||
raise ValueError("definition_type must be 'compose', 'dockerfile', or 'manifest'")
|
raise ValueError(
|
||||||
|
"definition_type must be 'compose', 'dockerfile', or 'manifest'"
|
||||||
|
)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("interface_type")
|
@field_validator("interface_type")
|
||||||
@@ -262,7 +288,10 @@ async def create_tool_type(
|
|||||||
# Check for duplicate name
|
# Check for duplicate name
|
||||||
existing = await session.scalar(select(ToolType).where(ToolType.name == data.name))
|
existing = await session.scalar(select(ToolType).where(ToolType.name == data.name))
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="tool type with this name already exists")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="tool type with this name already exists",
|
||||||
|
)
|
||||||
|
|
||||||
tool_type = ToolType(
|
tool_type = ToolType(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
@@ -336,7 +365,9 @@ async def get_tool_type(
|
|||||||
await _get_user(session, user_id)
|
await _get_user(session, user_id)
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||||
|
)
|
||||||
return tool_type
|
return tool_type
|
||||||
|
|
||||||
|
|
||||||
@@ -368,7 +399,9 @@ async def update_tool_type(
|
|||||||
|
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||||
|
)
|
||||||
|
|
||||||
# Built-in tool types can now be modified
|
# Built-in tool types can now be modified
|
||||||
|
|
||||||
@@ -381,7 +414,7 @@ async def update_tool_type(
|
|||||||
if new_port <= 0 or new_port > 65535:
|
if new_port <= 0 or new_port > 65535:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Port must be between 1 and 65535"
|
detail="Port must be between 1 and 65535",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only validate port exposure for compose definitions
|
# Only validate port exposure for compose definitions
|
||||||
@@ -394,12 +427,11 @@ async def update_tool_type(
|
|||||||
if not check_port_exposed(parsed, new_port):
|
if not check_port_exposed(parsed, new_port):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Port {new_port} is not exposed in the compose template"
|
detail=f"Port {new_port} is not exposed in the compose template",
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
|
||||||
detail=str(e)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate required variables for compose definitions
|
# Validate required variables for compose definitions
|
||||||
@@ -509,7 +541,9 @@ async def validate_tool_type(
|
|||||||
await _get_user(session, user_id)
|
await _get_user(session, user_id)
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||||
|
)
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
@@ -564,7 +598,9 @@ async def delete_tool_type(
|
|||||||
|
|
||||||
tool_type = await session.get(ToolType, tool_type_id)
|
tool_type = await session.get(ToolType, tool_type_id)
|
||||||
if tool_type is None:
|
if tool_type is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found"
|
||||||
|
)
|
||||||
|
|
||||||
# Built-in tool types can now be deleted
|
# Built-in tool types can now be deleted
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
||||||
|
|
||||||
|
|
||||||
async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> UserConfig:
|
async def _get_or_create_config(
|
||||||
|
session: AsyncSession, user_id: uuid.UUID
|
||||||
|
) -> UserConfig:
|
||||||
"""Get or create user config record.
|
"""Get or create user config record.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -24,7 +26,9 @@ async def _get_or_create_config(session: AsyncSession, user_id: uuid.UUID) -> Us
|
|||||||
Returns:
|
Returns:
|
||||||
The user's config, creating a new one if it doesn't exist.
|
The user's config, creating a new one if it doesn't exist.
|
||||||
"""
|
"""
|
||||||
result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id))
|
result = await session.execute(
|
||||||
|
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
config = result.scalar_one_or_none()
|
config = result.scalar_one_or_none()
|
||||||
if config is None:
|
if config is None:
|
||||||
config = UserConfig(user_id=user_id, config={})
|
config = UserConfig(user_id=user_id, config={})
|
||||||
@@ -42,6 +46,8 @@ class UserConfigResponse(BaseModel):
|
|||||||
git_user_name: str | None = None
|
git_user_name: str | None = None
|
||||||
git_user_email: str | None = None
|
git_user_email: str | None = None
|
||||||
last_session_id: str | None = None
|
last_session_id: str | None = None
|
||||||
|
notification_mute_categories: list[str] | None = None
|
||||||
|
notification_toast_level: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserConfigUpdate(BaseModel):
|
class UserConfigUpdate(BaseModel):
|
||||||
@@ -50,6 +56,8 @@ class UserConfigUpdate(BaseModel):
|
|||||||
git_user_name: str | None = None
|
git_user_name: str | None = None
|
||||||
git_user_email: str | None = None
|
git_user_email: str | None = None
|
||||||
last_session_id: str | None = None
|
last_session_id: str | None = None
|
||||||
|
notification_mute_categories: list[str] | None = None
|
||||||
|
notification_toast_level: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Workspace file API endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
from src.services.file_service import FileService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/workspaces/{workspace_id}/files")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_workspace(
|
||||||
|
session: AsyncSession,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> Workspace:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Workspace).where(
|
||||||
|
Workspace.id == workspace_id,
|
||||||
|
Workspace.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workspace = result.scalar_one_or_none()
|
||||||
|
if not workspace:
|
||||||
|
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_files(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
path: str = "",
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""List files in a workspace directory."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
service = FileService()
|
||||||
|
try:
|
||||||
|
entries = service.list_directory(workspace, path)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": e.name,
|
||||||
|
"path": e.path,
|
||||||
|
"type": e.type,
|
||||||
|
"size": e.size,
|
||||||
|
}
|
||||||
|
for e in entries
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/content")
|
||||||
|
async def get_file_content(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
path: str,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get the content of a text file."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
service = FileService()
|
||||||
|
try:
|
||||||
|
content = service.read_file(workspace, path)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"content": content, "path": path}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/content")
|
||||||
|
async def write_file(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Write a file and optionally commit."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
service = FileService()
|
||||||
|
|
||||||
|
file_path = data.get("path", "").strip()
|
||||||
|
content = data.get("content", "")
|
||||||
|
commit_message = data.get("message", "").strip()
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
raise HTTPException(status_code=400, detail="File path is required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
service.write_file(workspace, file_path, content)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
if commit_message:
|
||||||
|
from src.services.git_operations import GitOperations
|
||||||
|
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.commit(commit_message)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"status": "saved", "path": file_path}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Workspace git API endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
from src.services.git_operations import GitOperations
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/workspaces/{workspace_id}/git")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_workspace(
|
||||||
|
session: AsyncSession,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> Workspace:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Workspace).where(
|
||||||
|
Workspace.id == workspace_id,
|
||||||
|
Workspace.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workspace = result.scalar_one_or_none()
|
||||||
|
if not workspace:
|
||||||
|
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def git_status(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get git status for the workspace."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
status = await git.status()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"branch": status.branch,
|
||||||
|
"modified": status.modified,
|
||||||
|
"added": status.added,
|
||||||
|
"deleted": status.deleted,
|
||||||
|
"untracked": status.untracked,
|
||||||
|
"ahead": status.ahead,
|
||||||
|
"behind": status.behind,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/branches")
|
||||||
|
async def git_branches(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""List branches for the workspace."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
branches, current = await git.branches()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"branches": branches,
|
||||||
|
"current_branch": current,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/commit")
|
||||||
|
async def git_commit(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Stage all changes and commit."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
message = data.get("message", "").strip()
|
||||||
|
if not message:
|
||||||
|
raise HTTPException(status_code=400, detail="Commit message is required")
|
||||||
|
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.commit(message)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"status": "committed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/push")
|
||||||
|
async def git_push(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Push current branch."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.push()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"status": "pushed"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pull")
|
||||||
|
async def git_pull(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Pull current branch."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.pull()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"status": "pulled"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fetch")
|
||||||
|
async def git_fetch(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Fetch from origin."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.fetch()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {"status": "fetched"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/checkout")
|
||||||
|
async def git_checkout(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Checkout a branch."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
branch = data.get("branch", "").strip()
|
||||||
|
if not branch:
|
||||||
|
raise HTTPException(status_code=400, detail="Branch name is required")
|
||||||
|
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
await git.checkout(branch)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
workspace.branch = branch
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {"status": "checked_out", "branch": branch}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def git_history(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
path: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get commit history."""
|
||||||
|
workspace = await _get_workspace(session, workspace_id, user_id)
|
||||||
|
git = GitOperations(workspace)
|
||||||
|
try:
|
||||||
|
commits = await git.history(path, limit)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"commits": [
|
||||||
|
{
|
||||||
|
"hash": c.hash,
|
||||||
|
"message": c.message,
|
||||||
|
"author": c.author,
|
||||||
|
"date": c.date,
|
||||||
|
}
|
||||||
|
for c in commits
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Workspace instance API endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/workspaces/{workspace_id}/instances")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_workspace(
|
||||||
|
session: AsyncSession,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> Workspace:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Workspace).where(
|
||||||
|
Workspace.id == workspace_id,
|
||||||
|
Workspace.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workspace = result.scalar_one_or_none()
|
||||||
|
if not workspace:
|
||||||
|
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_workspace_instances(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List tool instances using this workspace."""
|
||||||
|
await _get_workspace(session, workspace_id, user_id)
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance)
|
||||||
|
.where(ToolInstance.workspace_id == workspace_id)
|
||||||
|
.order_by(ToolInstance.created_at.desc())
|
||||||
|
)
|
||||||
|
instances = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(i.id),
|
||||||
|
"name": i.name,
|
||||||
|
"display_name": i.display_name,
|
||||||
|
"status": i.status,
|
||||||
|
"tool_type_id": str(i.tool_type_id),
|
||||||
|
"url": i.url,
|
||||||
|
"port": i.port,
|
||||||
|
"created_at": i.created_at.isoformat() if i.created_at else None,
|
||||||
|
}
|
||||||
|
for i in instances
|
||||||
|
]
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
"""Workspace CRUD API endpoints."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
from src.services.workspace_manager import WorkspaceHasInstancesError, WorkspaceManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects/{project_id}/repositories/{repo_id}/workspaces")
|
||||||
|
all_workspaces_router = APIRouter(prefix="/workspaces")
|
||||||
|
|
||||||
|
|
||||||
|
@all_workspaces_router.get("/")
|
||||||
|
async def list_all_workspaces(
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List all workspaces for the current user across all repos."""
|
||||||
|
instance_count = (
|
||||||
|
select(func.count(ToolInstance.id))
|
||||||
|
.where(ToolInstance.workspace_id == Workspace.id)
|
||||||
|
.correlate(Workspace)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
Workspace,
|
||||||
|
GitRepository.name.label("repo_name"),
|
||||||
|
GitRepository.project_id,
|
||||||
|
GitRepository.ssh_key_id.label("repo_ssh_key_id"),
|
||||||
|
instance_count.label("instance_count"),
|
||||||
|
)
|
||||||
|
.join(GitRepository, Workspace.repo_id == GitRepository.id)
|
||||||
|
.where(Workspace.user_id == user_id)
|
||||||
|
.order_by(Workspace.created_at.desc())
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(ws.id),
|
||||||
|
"name": ws.name,
|
||||||
|
"repo_id": str(ws.repo_id),
|
||||||
|
"repo_name": repo_name or "",
|
||||||
|
"repo_ssh_key_id": str(ssh_key_id) if ssh_key_id else None,
|
||||||
|
"project_id": str(project_id) if project_id else "",
|
||||||
|
"project_name": "",
|
||||||
|
"user_id": str(ws.user_id),
|
||||||
|
"branch": ws.branch,
|
||||||
|
"path": ws.path,
|
||||||
|
"status": ws.status,
|
||||||
|
"last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None,
|
||||||
|
"created_at": ws.created_at.isoformat() if ws.created_at else None,
|
||||||
|
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
|
||||||
|
"instance_count": count or 0,
|
||||||
|
}
|
||||||
|
for ws, repo_name, project_id, ssh_key_id, count in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@all_workspaces_router.delete("/{workspace_id}")
|
||||||
|
async def delete_workspace_top_level(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
force: bool = Query(False),
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Delete a workspace via top-level path."""
|
||||||
|
workspace = await session.get(Workspace, workspace_id)
|
||||||
|
if not workspace or workspace.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||||
|
|
||||||
|
manager = WorkspaceManager()
|
||||||
|
try:
|
||||||
|
await manager.delete(workspace, force=force, session=session)
|
||||||
|
await session.commit()
|
||||||
|
except WorkspaceHasInstancesError as exc:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": "Workspace has running tool instances",
|
||||||
|
"instances": exc.instances,
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("Failed to delete workspace: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Failed to delete workspace"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@all_workspaces_router.post("/")
|
||||||
|
async def create_workspace_top_level(
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Create a workspace directly (no nested project/repo path)."""
|
||||||
|
repo_id_str = data.get("repo_id", "").strip()
|
||||||
|
if not repo_id_str:
|
||||||
|
raise HTTPException(status_code=400, detail="repo_id is required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
repo_id = uuid.UUID(repo_id_str)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid repo_id format") from exc
|
||||||
|
|
||||||
|
repo = await session.get(GitRepository, repo_id)
|
||||||
|
if not repo or repo.owner_id != user_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Repository not found")
|
||||||
|
|
||||||
|
name = data.get("name", "").strip()
|
||||||
|
branch = data.get("branch", "main").strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="Workspace name is required")
|
||||||
|
|
||||||
|
manager = WorkspaceManager()
|
||||||
|
try:
|
||||||
|
workspace = await manager.create(repo, user_id, name, branch, session=session)
|
||||||
|
session.add(workspace)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("Failed to create workspace: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Workspace name already exists for this repository",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
await session.refresh(workspace)
|
||||||
|
return {
|
||||||
|
"id": str(workspace.id),
|
||||||
|
"name": workspace.name,
|
||||||
|
"repo_id": str(workspace.repo_id),
|
||||||
|
"branch": workspace.branch,
|
||||||
|
"path": workspace.path,
|
||||||
|
"status": workspace.status,
|
||||||
|
"created_at": workspace.created_at.isoformat()
|
||||||
|
if workspace.created_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_workspaces(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List workspaces for a repository, with instance counts."""
|
||||||
|
# Verify repo belongs to project and user
|
||||||
|
repo = await _get_repo(session, repo_id, project_id, user_id)
|
||||||
|
|
||||||
|
# Build subquery for instance counts
|
||||||
|
instance_count = (
|
||||||
|
select(func.count(ToolInstance.id))
|
||||||
|
.where(ToolInstance.workspace_id == Workspace.id)
|
||||||
|
.correlate(Workspace)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
Workspace,
|
||||||
|
instance_count.label("instance_count"),
|
||||||
|
)
|
||||||
|
.where(Workspace.repo_id == repo_id)
|
||||||
|
.order_by(Workspace.created_at.desc())
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(ws.id),
|
||||||
|
"name": ws.name,
|
||||||
|
"repo_id": str(ws.repo_id),
|
||||||
|
"repo_name": repo.name,
|
||||||
|
"repo_ssh_key_id": str(repo.ssh_key_id) if repo.ssh_key_id else None,
|
||||||
|
"project_id": str(repo.project_id) if repo.project_id else "",
|
||||||
|
"project_name": repo.project.name if repo.project else "",
|
||||||
|
"user_id": str(ws.user_id),
|
||||||
|
"branch": ws.branch,
|
||||||
|
"path": ws.path,
|
||||||
|
"status": ws.status,
|
||||||
|
"last_sync_at": ws.last_sync_at.isoformat() if ws.last_sync_at else None,
|
||||||
|
"created_at": ws.created_at.isoformat() if ws.created_at else None,
|
||||||
|
"updated_at": ws.updated_at.isoformat() if ws.updated_at else None,
|
||||||
|
"instance_count": count or 0,
|
||||||
|
}
|
||||||
|
for ws, count in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
async def create_workspace(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Create a new workspace by cloning a repository branch."""
|
||||||
|
repo = await _get_repo(session, repo_id, project_id, user_id)
|
||||||
|
|
||||||
|
name = data.get("name", "").strip()
|
||||||
|
branch = data.get("branch", "main").strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="Workspace name is required")
|
||||||
|
if not branch:
|
||||||
|
raise HTTPException(status_code=400, detail="Branch is required")
|
||||||
|
|
||||||
|
manager = WorkspaceManager()
|
||||||
|
try:
|
||||||
|
workspace = await manager.create(repo, user_id, name, branch, session=session)
|
||||||
|
session.add(workspace)
|
||||||
|
await session.commit()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except ValueError as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("Failed to create workspace: %s", exc)
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("Failed to create workspace: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Workspace name already exists for this repository",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
await session.refresh(workspace)
|
||||||
|
return {
|
||||||
|
"id": str(workspace.id),
|
||||||
|
"name": workspace.name,
|
||||||
|
"repo_id": str(workspace.repo_id),
|
||||||
|
"branch": workspace.branch,
|
||||||
|
"path": workspace.path,
|
||||||
|
"status": workspace.status,
|
||||||
|
"created_at": workspace.created_at.isoformat()
|
||||||
|
if workspace.created_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{workspace_id}")
|
||||||
|
async def get_workspace_detail(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get workspace details."""
|
||||||
|
repo = await _get_repo(session, repo_id, project_id, user_id)
|
||||||
|
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||||
|
|
||||||
|
# Count instances
|
||||||
|
result = await session.execute(
|
||||||
|
select(func.count(ToolInstance.id)).where(
|
||||||
|
ToolInstance.workspace_id == workspace_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
instance_count = result.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(workspace.id),
|
||||||
|
"name": workspace.name,
|
||||||
|
"repo_id": str(workspace.repo_id),
|
||||||
|
"repo_name": repo.name,
|
||||||
|
"user_id": str(workspace.user_id),
|
||||||
|
"branch": workspace.branch,
|
||||||
|
"path": workspace.path,
|
||||||
|
"status": workspace.status,
|
||||||
|
"last_sync_at": workspace.last_sync_at.isoformat()
|
||||||
|
if workspace.last_sync_at
|
||||||
|
else None,
|
||||||
|
"created_at": workspace.created_at.isoformat()
|
||||||
|
if workspace.created_at
|
||||||
|
else None,
|
||||||
|
"updated_at": workspace.updated_at.isoformat()
|
||||||
|
if workspace.updated_at
|
||||||
|
else None,
|
||||||
|
"instance_count": instance_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{workspace_id}")
|
||||||
|
async def update_workspace(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
data: dict,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Update workspace name or branch."""
|
||||||
|
await _get_repo(session, repo_id, project_id, user_id)
|
||||||
|
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||||
|
|
||||||
|
new_name = data.get("name", "").strip()
|
||||||
|
new_branch = data.get("branch", "").strip()
|
||||||
|
|
||||||
|
if new_name:
|
||||||
|
workspace.name = new_name
|
||||||
|
if new_branch:
|
||||||
|
workspace.branch = new_branch
|
||||||
|
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("Failed to update workspace: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Workspace name already exists for this repository",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(workspace.id),
|
||||||
|
"name": workspace.name,
|
||||||
|
"branch": workspace.branch,
|
||||||
|
"status": workspace.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{workspace_id}")
|
||||||
|
async def delete_workspace(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
force: bool = Query(False),
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Delete a workspace. Returns 409 if instances exist and force=False."""
|
||||||
|
await _get_repo(session, repo_id, project_id, user_id)
|
||||||
|
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||||
|
|
||||||
|
manager = WorkspaceManager()
|
||||||
|
try:
|
||||||
|
await manager.delete(workspace, force=force, session=session)
|
||||||
|
await session.commit()
|
||||||
|
except WorkspaceHasInstancesError as exc:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": "Workspace has running tool instances",
|
||||||
|
"instances": exc.instances,
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error("Failed to delete workspace: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Failed to delete workspace"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{workspace_id}/sync")
|
||||||
|
async def sync_workspace(
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Sync workspace with remote. Returns 409 if branch was deleted."""
|
||||||
|
await _get_repo(session, repo_id, project_id, user_id)
|
||||||
|
workspace = await _get_workspace(session, workspace_id, repo_id)
|
||||||
|
|
||||||
|
manager = WorkspaceManager()
|
||||||
|
result = await manager.sync(workspace, session=session)
|
||||||
|
|
||||||
|
if result.branch_deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"message": f"Branch '{workspace.branch}' was deleted from remote",
|
||||||
|
"branch_deleted": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return {
|
||||||
|
"branch_deleted": False,
|
||||||
|
"pulled": True,
|
||||||
|
"last_sync_at": workspace.last_sync_at.isoformat()
|
||||||
|
if workspace.last_sync_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_repo(
|
||||||
|
session: AsyncSession,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> GitRepository:
|
||||||
|
"""Fetch and validate repository access."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(GitRepository)
|
||||||
|
.where(
|
||||||
|
GitRepository.id == repo_id,
|
||||||
|
GitRepository.project_id == project_id,
|
||||||
|
)
|
||||||
|
.options(selectinload(GitRepository.project))
|
||||||
|
)
|
||||||
|
repo = result.scalar_one_or_none()
|
||||||
|
if not repo:
|
||||||
|
raise HTTPException(status_code=404, detail="Repository not found")
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_workspace(
|
||||||
|
session: AsyncSession,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
repo_id: uuid.UUID,
|
||||||
|
) -> Workspace:
|
||||||
|
"""Fetch and validate workspace."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(Workspace).where(
|
||||||
|
Workspace.id == workspace_id,
|
||||||
|
Workspace.repo_id == repo_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workspace = result.scalar_one_or_none()
|
||||||
|
if not workspace:
|
||||||
|
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||||
|
return workspace
|
||||||
@@ -1,15 +1,52 @@
|
|||||||
|
"""Structured JSON logging configuration."""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
from src.services.correlation import get_correlation_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CorrelationIdFilter(logging.Filter):
|
||||||
|
"""Inject correlation_id into every log record from context var."""
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
record.correlation_id = get_correlation_id() # type: ignore[attr-defined]
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class JSONFormatter(logging.Formatter):
|
||||||
|
"""Emit log records as single-line JSON."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
log_obj: dict = {
|
||||||
|
"timestamp": self.formatTime(record),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
"correlation_id": getattr(record, "correlation_id", None),
|
||||||
|
}
|
||||||
|
# Optional extra fields
|
||||||
|
for key in ("instance_id", "event_type"):
|
||||||
|
value = getattr(record, key, None)
|
||||||
|
if value is not None:
|
||||||
|
log_obj[key] = value
|
||||||
|
if record.exc_info:
|
||||||
|
log_obj["exception"] = self.formatException(record.exc_info)
|
||||||
|
return json.dumps(log_obj, default=str)
|
||||||
|
|
||||||
|
def formatTime(self, record: logging.LogRecord, datefmt: str | None = None) -> str:
|
||||||
|
return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(record.created))
|
||||||
|
|
||||||
|
|
||||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||||
"""Log all HTTP requests with timing and status codes."""
|
"""Log all HTTP requests with timing and status codes."""
|
||||||
|
|
||||||
@@ -17,7 +54,6 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
client_host = request.client.host if request.client else "unknown"
|
client_host = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
# Log the incoming request
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"→ Request: %s %s (client: %s)",
|
"→ Request: %s %s (client: %s)",
|
||||||
request.method,
|
request.method,
|
||||||
@@ -29,7 +65,6 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
|
|
||||||
# Log the response
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"← Response: %s %s → %d (%dms)",
|
"← Response: %s %s → %d (%dms)",
|
||||||
request.method,
|
request.method,
|
||||||
@@ -69,15 +104,13 @@ class ExceptionLoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
|
|
||||||
def configure_logging(level: int = logging.INFO) -> None:
|
def configure_logging(level: int = logging.INFO) -> None:
|
||||||
"""Configure structured logging for the application."""
|
"""Configure structured JSON logging for the application."""
|
||||||
formatter = logging.Formatter(
|
formatter = JSONFormatter()
|
||||||
fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Console handler
|
# Console handler
|
||||||
console_handler = logging.StreamHandler(sys.stdout)
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
console_handler.setFormatter(formatter)
|
console_handler.setFormatter(formatter)
|
||||||
|
console_handler.addFilter(CorrelationIdFilter())
|
||||||
|
|
||||||
# Configure root logger
|
# Configure root logger
|
||||||
root_logger = logging.getLogger()
|
root_logger = logging.getLogger()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from src.api.auth import router as auth_router
|
from src.api.auth import router as auth_router
|
||||||
from src.api.dashboard import router as dashboard_router
|
from src.api.dashboard import router as dashboard_router
|
||||||
|
from src.api.events import router as events_router
|
||||||
from src.api.git_repositories import router as git_repositories_router
|
from src.api.git_repositories import router as git_repositories_router
|
||||||
from src.api.health import router as health_router
|
from src.api.health import router as health_router
|
||||||
from src.api.projects import router as projects_router
|
from src.api.projects import router as projects_router
|
||||||
@@ -20,9 +21,15 @@ from src.api.tool_definitions import router as tool_definitions_router
|
|||||||
from src.api.tool_instances import router as tool_instances_router
|
from src.api.tool_instances import router as tool_instances_router
|
||||||
from src.api.tool_instances import sessions_router
|
from src.api.tool_instances import sessions_router
|
||||||
from src.api.tool_types import router as tool_types_router
|
from src.api.tool_types import router as tool_types_router
|
||||||
|
from src.api.notifications import router as notifications_router
|
||||||
from src.api.user_config import router as user_config_router
|
from src.api.user_config import router as user_config_router
|
||||||
from src.api.users import router as users_router
|
from src.api.users import router as users_router
|
||||||
|
from src.api.workspace_files import router as workspace_files_router
|
||||||
|
from src.api.workspace_git import router as workspace_git_router
|
||||||
|
from src.api.workspace_instances import router as workspace_instances_router
|
||||||
|
from src.api.workspaces import all_workspaces_router, router as workspaces_router
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
from src.models.notification import Notification # noqa: F401 – Alembic model discovery
|
||||||
from src.models.terminal_session import TerminalSessionModel # noqa: F401 – Alembic model discovery
|
from src.models.terminal_session import TerminalSessionModel # noqa: F401 – Alembic model discovery
|
||||||
from src.database import init_database
|
from src.database import init_database
|
||||||
from src.logging_config import (
|
from src.logging_config import (
|
||||||
@@ -30,6 +37,9 @@ from src.logging_config import (
|
|||||||
RequestLoggingMiddleware,
|
RequestLoggingMiddleware,
|
||||||
configure_logging,
|
configure_logging,
|
||||||
)
|
)
|
||||||
|
from src.services.correlation import CorrelationIdMiddleware
|
||||||
|
from src.services.event_bus import InstanceEventBus
|
||||||
|
from src.services.health_monitor import HealthMonitor
|
||||||
|
|
||||||
# Configure logging early
|
# Configure logging early
|
||||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
@@ -54,6 +64,7 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.add_middleware(CorrelationIdMiddleware)
|
||||||
app.add_middleware(RequestLoggingMiddleware)
|
app.add_middleware(RequestLoggingMiddleware)
|
||||||
app.add_middleware(ExceptionLoggingMiddleware)
|
app.add_middleware(ExceptionLoggingMiddleware)
|
||||||
|
|
||||||
@@ -103,6 +114,11 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global services
|
||||||
|
_event_bus = InstanceEventBus()
|
||||||
|
_health_monitor = HealthMonitor(_event_bus)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def on_startup():
|
async def on_startup():
|
||||||
logger.info("Starting up Headquarter API...")
|
logger.info("Starting up Headquarter API...")
|
||||||
@@ -115,9 +131,21 @@ async def on_startup():
|
|||||||
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Start background health monitor
|
||||||
|
_health_monitor.start()
|
||||||
|
logger.info("Health monitor started")
|
||||||
|
|
||||||
logger.info("Startup complete.")
|
logger.info("Startup complete.")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def on_shutdown():
|
||||||
|
logger.info("Shutting down Headquarter API...")
|
||||||
|
_health_monitor.stop()
|
||||||
|
logger.info("Health monitor stopped")
|
||||||
|
logger.info("Shutdown complete.")
|
||||||
|
|
||||||
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
@@ -133,4 +161,11 @@ app.include_router(tool_instances_router)
|
|||||||
app.include_router(sessions_router)
|
app.include_router(sessions_router)
|
||||||
app.include_router(instance_proxy_router)
|
app.include_router(instance_proxy_router)
|
||||||
app.include_router(terminal_router)
|
app.include_router(terminal_router)
|
||||||
|
app.include_router(events_router)
|
||||||
|
app.include_router(notifications_router)
|
||||||
|
app.include_router(all_workspaces_router)
|
||||||
|
app.include_router(workspaces_router)
|
||||||
|
app.include_router(workspace_files_router)
|
||||||
|
app.include_router(workspace_git_router)
|
||||||
|
app.include_router(workspace_instances_router)
|
||||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from src.models.base import Base
|
from src.models.base import Base
|
||||||
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||||
from src.models.git_repository import GitRepository
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.health_check import HealthCheck
|
||||||
|
from src.models.instance_event import InstanceEvent
|
||||||
|
from src.models.notification import Notification
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
from src.models.terminal_session import TerminalSessionModel
|
from src.models.terminal_session import TerminalSessionModel
|
||||||
@@ -9,12 +12,16 @@ from src.models.tool_instance import ToolInstance
|
|||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
from src.models.user_config import UserConfig
|
from src.models.user_config import UserConfig
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"ConfigProfile",
|
"ConfigProfile",
|
||||||
"ConfigProfileInclude",
|
"ConfigProfileInclude",
|
||||||
"GitRepository",
|
"GitRepository",
|
||||||
|
"HealthCheck",
|
||||||
|
"InstanceEvent",
|
||||||
|
"Notification",
|
||||||
"Project",
|
"Project",
|
||||||
"SSHKey",
|
"SSHKey",
|
||||||
"TerminalSessionModel",
|
"TerminalSessionModel",
|
||||||
@@ -23,4 +30,5 @@ __all__ = [
|
|||||||
"ToolType",
|
"ToolType",
|
||||||
"User",
|
"User",
|
||||||
"UserConfig",
|
"UserConfig",
|
||||||
|
"Workspace",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""SQLAlchemy model for health check snapshots."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, Uuid, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.models.base import Base, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class HealthCheck(UUIDPrimaryKeyMixin, Base):
|
||||||
|
__tablename__ = "health_checks"
|
||||||
|
|
||||||
|
instance_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("tool_instances.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
container_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
container_healthy: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||||
|
tunnel_healthy: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||||
|
exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
probe_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
probe_output: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
checked_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""SQLAlchemy model for instance lifecycle event audit rows."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, JSON, String, Text, Uuid, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.models.base import Base, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceEvent(UUIDPrimaryKeyMixin, Base):
|
||||||
|
__tablename__ = "instance_events"
|
||||||
|
|
||||||
|
instance_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("tool_instances.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
status: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
event_metadata: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata",
|
||||||
|
JSON,
|
||||||
|
nullable=False,
|
||||||
|
default=dict,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Notification SQLAlchemy model."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, JSON, String, Text
|
||||||
|
from sqlalchemy import Uuid as UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from src.models.base import Base, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(UUIDPrimaryKeyMixin, Base):
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
category: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
severity: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
source_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
source_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), nullable=True
|
||||||
|
)
|
||||||
|
notification_metadata: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata", JSON, nullable=False, default=dict
|
||||||
|
)
|
||||||
|
read_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, index=True
|
||||||
|
)
|
||||||
|
dismissed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||||
|
)
|
||||||
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
|
|||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
|
||||||
|
|
||||||
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
@@ -59,8 +60,13 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
ssh_key_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
workspace_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(), ForeignKey("workspaces.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
tool_type: Mapped["ToolType"] = relationship()
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
|
workspace: Mapped["Workspace | None"] = relationship()
|
||||||
repository: Mapped["GitRepository"] = relationship()
|
repository: Mapped["GitRepository"] = relationship()
|
||||||
project: Mapped["Project"] = relationship()
|
project: Mapped["Project"] = relationship()
|
||||||
owner: Mapped["User"] = relationship()
|
owner: Mapped["User"] = relationship()
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||||||
|
|
||||||
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Workspace model for persistent writable repo clones."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class Workspace(Base, TimestampMixin):
|
||||||
|
"""A persistent, writable local clone of a Git repository.
|
||||||
|
|
||||||
|
Users create workspaces explicitly, then start tool instances on them.
|
||||||
|
Multiple tool instances can share the same workspace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "workspaces"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("git_repositories.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
branch: Mapped[str] = mapped_column(String(255), nullable=False, default="main")
|
||||||
|
path: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="ready")
|
||||||
|
last_sync_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("repo_id", "name", name="uq_workspace_repo_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
repository: Mapped[GitRepository] = relationship("GitRepository")
|
||||||
|
owner: Mapped[User] = relationship("User")
|
||||||
@@ -5,6 +5,7 @@ and cycle protection.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -57,7 +58,9 @@ class ResolvedProfile:
|
|||||||
included_profiles: list[dict[str, Any]] = field(default_factory=list)
|
included_profiles: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
def _detect_cycle(profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]) -> bool:
|
def _detect_cycle(
|
||||||
|
profile_id: uuid.UUID, visited: set[uuid.UUID], path: list[uuid.UUID]
|
||||||
|
) -> bool:
|
||||||
"""Detect if adding profile_id to path would create a cycle.
|
"""Detect if adding profile_id to path would create a cycle.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -176,21 +179,59 @@ def _merge_git_mounts(
|
|||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Merge git mounts from included profiles.
|
"""Merge git mounts from included profiles.
|
||||||
|
|
||||||
Later mounts override earlier ones with the same remote_url + target_path combo.
|
Entries with the same remote_url + branch have their mappings concatenated.
|
||||||
|
Different repos are kept as separate entries.
|
||||||
|
All entries are normalized to the mappings format.
|
||||||
"""
|
"""
|
||||||
result = list(base)
|
result = list(base)
|
||||||
# Build lookup by (remote_url, target_path)
|
# Normalize existing entries to mappings format
|
||||||
seen = {(m["remote_url"], m["target_path"]): i for i, m in enumerate(result)}
|
for i, m in enumerate(result):
|
||||||
|
result[i] = _normalize_git_mount_entry(dict(m))
|
||||||
|
|
||||||
|
# Build lookup by (remote_url, branch)
|
||||||
|
seen = {}
|
||||||
|
for i, m in enumerate(result):
|
||||||
|
key = (m["remote_url"], m.get("branch"))
|
||||||
|
seen[key] = i
|
||||||
|
|
||||||
for mount in overlay:
|
for mount in overlay:
|
||||||
key = (mount["remote_url"], mount["target_path"])
|
mount = _normalize_git_mount_entry(dict(mount))
|
||||||
|
key = (mount["remote_url"], mount.get("branch"))
|
||||||
if key in seen:
|
if key in seen:
|
||||||
result[seen[key]] = dict(mount)
|
# Same repo+branch: concatenate mappings, dedup by (source_path, target_path)
|
||||||
|
existing = result[seen[key]]
|
||||||
|
existing_sources = {
|
||||||
|
(m["source_path"], m["target_path"])
|
||||||
|
for m in existing.get("mappings", [])
|
||||||
|
}
|
||||||
|
for mapping in mount.get("mappings", []):
|
||||||
|
map_key = (mapping["source_path"], mapping["target_path"])
|
||||||
|
if map_key not in existing_sources:
|
||||||
|
existing["mappings"].append(dict(mapping))
|
||||||
|
existing_sources.add(map_key)
|
||||||
else:
|
else:
|
||||||
seen[key] = len(result)
|
seen[key] = len(result)
|
||||||
result.append(dict(mount))
|
result.append(mount)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_git_mount_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Normalize a git mount entry to the unified mappings format.
|
||||||
|
|
||||||
|
Converts legacy source_path + target_path into a single-entry mappings array.
|
||||||
|
"""
|
||||||
|
entry = dict(entry)
|
||||||
|
if "mappings" not in entry or not entry.get("mappings"):
|
||||||
|
source = entry.get("source_path", ".")
|
||||||
|
target = entry.get("target_path")
|
||||||
|
if target is not None:
|
||||||
|
entry["mappings"] = [{"source_path": source, "target_path": target}]
|
||||||
|
# Remove legacy fields once normalized
|
||||||
|
entry.pop("source_path", None)
|
||||||
|
entry.pop("target_path", None)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_profile_recursive(
|
async def _resolve_profile_recursive(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
profile_id: uuid.UUID,
|
profile_id: uuid.UUID,
|
||||||
@@ -214,7 +255,9 @@ async def _resolve_profile_recursive(
|
|||||||
"""
|
"""
|
||||||
if _detect_cycle(profile_id, visited, path):
|
if _detect_cycle(profile_id, visited, path):
|
||||||
cycle_path = " -> ".join(str(p) for p in path + [profile_id])
|
cycle_path = " -> ".join(str(p) for p in path + [profile_id])
|
||||||
raise ConfigProfileCycleError(f"Cycle detected in profile includes: {cycle_path}")
|
raise ConfigProfileCycleError(
|
||||||
|
f"Cycle detected in profile includes: {cycle_path}"
|
||||||
|
)
|
||||||
|
|
||||||
profile = await session.get(ConfigProfile, profile_id)
|
profile = await session.get(ConfigProfile, profile_id)
|
||||||
if profile is None:
|
if profile is None:
|
||||||
@@ -241,13 +284,18 @@ async def _resolve_profile_recursive(
|
|||||||
included = await _resolve_profile_recursive(
|
included = await _resolve_profile_recursive(
|
||||||
session, include.included_profile_id, new_visited, new_path
|
session, include.included_profile_id, new_visited, new_path
|
||||||
)
|
)
|
||||||
result.included_profiles.append({
|
result.included_profiles.append(
|
||||||
|
{
|
||||||
"id": str(included.profile_id),
|
"id": str(included.profile_id),
|
||||||
"name": included.profile_name,
|
"name": included.profile_name,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
result.env_vars = _merge_env_vars(
|
result.env_vars = _merge_env_vars(
|
||||||
result.env_vars, included.env_vars, result.env_overrides, included.profile_name
|
result.env_vars,
|
||||||
|
included.env_vars,
|
||||||
|
result.env_overrides,
|
||||||
|
included.profile_name,
|
||||||
)
|
)
|
||||||
result.runtime_hints = _merge_runtime_hints(
|
result.runtime_hints = _merge_runtime_hints(
|
||||||
result.runtime_hints,
|
result.runtime_hints,
|
||||||
@@ -391,6 +439,7 @@ async def check_include_cycle(
|
|||||||
def apply_resolved_profile(
|
def apply_resolved_profile(
|
||||||
instance_dir: str,
|
instance_dir: str,
|
||||||
resolved: ResolvedProfile,
|
resolved: ResolvedProfile,
|
||||||
|
home_dir: str = "/root",
|
||||||
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
|
) -> tuple[dict[str, str], dict[str, str], list[dict], dict[str, Any]]:
|
||||||
"""Apply a resolved profile to an instance directory.
|
"""Apply a resolved profile to an instance directory.
|
||||||
|
|
||||||
@@ -420,14 +469,19 @@ def apply_resolved_profile(
|
|||||||
try:
|
try:
|
||||||
full_path.resolve().relative_to(instance_path.resolve())
|
full_path.resolve().relative_to(instance_path.resolve())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning("Profile file path escapes instance directory: %s", file_path)
|
logger.warning(
|
||||||
|
"Profile file path escapes instance directory: %s", file_path
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
full_path.write_text(content)
|
full_path.write_text(content)
|
||||||
|
|
||||||
# Stage mount files and prepare volume mounts
|
# Stage mount files and prepare volume mounts
|
||||||
for mount in resolved.mounts.values():
|
for mount in resolved.mounts.values():
|
||||||
mount_dir = instance_path / "mounts" / mount.target.lstrip("/").replace("/", "_")
|
expanded_target = expand_container_path(mount.target, home_dir)
|
||||||
|
mount_dir = (
|
||||||
|
instance_path / "mounts" / expanded_target.lstrip("/").replace("/", "_")
|
||||||
|
)
|
||||||
mount_dir.mkdir(parents=True, exist_ok=True)
|
mount_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
for file_path, content in mount.files.items():
|
for file_path, content in mount.files.items():
|
||||||
@@ -440,15 +494,44 @@ def apply_resolved_profile(
|
|||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
full_path.write_text(content)
|
full_path.write_text(content)
|
||||||
|
|
||||||
volume_mounts.append({
|
# Mount each file individually so sibling files from other mounts
|
||||||
"source": str(mount_dir),
|
# (e.g. git repo directories) are preserved.
|
||||||
"target": mount.target,
|
file_target = os.path.join(expanded_target, file_path)
|
||||||
|
volume_mounts.append(
|
||||||
|
{
|
||||||
|
"source": str(full_path),
|
||||||
|
"target": file_target,
|
||||||
"type": "bind",
|
"type": "bind",
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return env_vars, files, volume_mounts, resolved.runtime_hints
|
return env_vars, files, volume_mounts, resolved.runtime_hints
|
||||||
|
|
||||||
|
|
||||||
|
def expand_container_path(path: str, home_dir: str) -> str:
|
||||||
|
"""Expand ~ and $HOME in a container path to the actual home directory.
|
||||||
|
|
||||||
|
Only expands at the start of the path (e.g., ~/foo, $HOME/foo, $HOME).
|
||||||
|
Leaves mid-string occurrences unchanged.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Container path that may contain ~ or $HOME.
|
||||||
|
home_dir: The container's home directory (e.g., /home/user or /root).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path with ~ and $HOME expanded.
|
||||||
|
"""
|
||||||
|
if path.startswith("~/"):
|
||||||
|
return os.path.join(home_dir, path[2:])
|
||||||
|
if path == "~":
|
||||||
|
return home_dir
|
||||||
|
if path.startswith("$HOME/"):
|
||||||
|
return home_dir + "/" + path[6:]
|
||||||
|
if path == "$HOME":
|
||||||
|
return home_dir
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
|
def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
|
||||||
"""Convert a ResolvedProfile to a plain dict for serialization.
|
"""Convert a ResolvedProfile to a plain dict for serialization.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Async correlation ID context variable and helpers."""
|
||||||
|
|
||||||
|
import contextvars
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
|
CORRELATION_ID: contextvars.ContextVar[str] = contextvars.ContextVar("correlation_id")
|
||||||
|
|
||||||
|
|
||||||
|
def get_correlation_id() -> str:
|
||||||
|
"""Return the current correlation ID or generate a new UUID."""
|
||||||
|
try:
|
||||||
|
return CORRELATION_ID.get()
|
||||||
|
except LookupError:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class CorrelationIdMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Set correlation ID from X-Request-ID header or generate a new UUID."""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
request_id = request.headers.get("X-Request-ID")
|
||||||
|
correlation_id = request_id or str(uuid.uuid4())
|
||||||
|
token = CORRELATION_ID.set(correlation_id)
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Request-ID"] = correlation_id
|
||||||
|
return response
|
||||||
|
finally:
|
||||||
|
CORRELATION_ID.reset(token)
|
||||||
+167
-239
@@ -1,12 +1,52 @@
|
|||||||
"""Docker service for managing tool instances."""
|
"""Docker service for managing tool instances."""
|
||||||
|
|
||||||
import os
|
import logging
|
||||||
import re
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
from collections import Counter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
|
||||||
|
"""Sort volume strings so parent paths come before child paths.
|
||||||
|
|
||||||
|
Docker Compose mounts volumes in array order. A later mount at a parent
|
||||||
|
path hides earlier mounts at child paths. By sorting shallow paths first
|
||||||
|
and deep paths last, deeper (more specific) mounts overlay correctly.
|
||||||
|
|
||||||
|
Volume format: source:target or source:target:type
|
||||||
|
|
||||||
|
Args:
|
||||||
|
volumes: List of Docker volume mount strings.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sorted list with parent paths before child paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _target_depth(vol: str) -> int:
|
||||||
|
parts = vol.split(":")
|
||||||
|
if len(parts) < 2:
|
||||||
|
return 0
|
||||||
|
target = parts[1].rstrip("/")
|
||||||
|
if not target or target == "/":
|
||||||
|
return 0
|
||||||
|
return target.count("/")
|
||||||
|
|
||||||
|
# Detect duplicate targets and warn
|
||||||
|
targets = []
|
||||||
|
for vol in volumes:
|
||||||
|
parts = vol.split(":")
|
||||||
|
targets.append(parts[1] if len(parts) > 1 else "")
|
||||||
|
dupes = [t for t, c in Counter(targets).items() if c > 1]
|
||||||
|
if dupes:
|
||||||
|
logger.warning("Duplicate mount targets detected: %s", dupes)
|
||||||
|
|
||||||
|
# Stable sort: parent paths first, child paths last
|
||||||
|
return sorted(volumes, key=_target_depth)
|
||||||
|
|
||||||
|
|
||||||
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
||||||
"""Render a Docker Compose template with variable substitution.
|
"""Render a Docker Compose template with variable substitution.
|
||||||
@@ -117,7 +157,7 @@ def execute_compose_command(
|
|||||||
cmd.extend(["--env-file", env_file])
|
cmd.extend(["--env-file", env_file])
|
||||||
|
|
||||||
if action == "up":
|
if action == "up":
|
||||||
cmd.extend(["up", "-d"])
|
cmd.extend(["up", "-d", "--force-recreate"])
|
||||||
elif action == "down":
|
elif action == "down":
|
||||||
cmd.extend(["down", "-v"])
|
cmd.extend(["down", "-v"])
|
||||||
elif action in ("start", "stop", "restart"):
|
elif action in ("start", "stop", "restart"):
|
||||||
@@ -139,69 +179,113 @@ def execute_compose_command(
|
|||||||
def get_container_id(instance_name: str) -> str | None:
|
def get_container_id(instance_name: str) -> str | None:
|
||||||
"""Get the container ID for a compose service.
|
"""Get the container ID for a compose service.
|
||||||
|
|
||||||
Searches all containers including stopped/exited ones.
|
Uses exact name matching to avoid substring collisions with tunnel
|
||||||
|
containers (e.g. tunnel-code-server-... matching code-server-...).
|
||||||
|
Falls back to case-insensitive matching since Docker DNS is case-
|
||||||
|
insensitive but docker inspect is case-sensitive.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_name: The service name in compose
|
instance_name: The expected container name.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Container ID or None if not found
|
Container ID or None if not found.
|
||||||
"""
|
"""
|
||||||
# Docker container names are lowercase internally; normalize to ensure match
|
expected = instance_name.lower()
|
||||||
|
|
||||||
|
# Fast path: exact match via docker inspect
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "ps", "-a", "-q", "--filter", f"name={instance_name.lower()}"],
|
["docker", "inspect", "-f", "{{.Id}}", expected],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
return result.stdout.strip().split("\n")[0]
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
# Fallback: list all containers and do case-insensitive exact match
|
||||||
|
ps_result = subprocess.run(
|
||||||
|
["docker", "ps", "-a", "--format", "{{.Names}}\t{{.ID}}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if ps_result.returncode == 0:
|
||||||
|
for line in ps_result.stdout.strip().splitlines():
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) == 2:
|
||||||
|
name, cid = parts
|
||||||
|
if name.lower() == expected:
|
||||||
|
return cid
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_container_name(instance_name: str) -> str | None:
|
def get_container_name(instance_name: str) -> str | None:
|
||||||
"""Get the full container name for a compose service.
|
"""Get the full container name for a compose service.
|
||||||
|
|
||||||
Searches all containers including stopped/exited ones.
|
Uses exact name matching via docker inspect to avoid substring collisions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_name: The service name in compose
|
instance_name: The exact container name (case-insensitive for Docker).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Container name or None if not found
|
Container name or None if not found.
|
||||||
"""
|
"""
|
||||||
# Docker container names are lowercase internally; normalize to ensure match
|
result = subprocess.run(
|
||||||
|
["docker", "inspect", "-f", "{{.Name}}", instance_name.lower()],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip().lstrip("/")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_backend_network_name() -> str:
|
||||||
|
"""Auto-detect the actual Docker network name for the backend network.
|
||||||
|
|
||||||
|
Docker Compose prefixes network names with the project directory name
|
||||||
|
(e.g. 'headquarter_backend' instead of 'backend'). We inspect the API
|
||||||
|
container itself to find the real network name it's connected to.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The actual Docker network name, or 'backend' as fallback.
|
||||||
|
"""
|
||||||
|
# Try to find the API container by its known name
|
||||||
|
api_container = "hq-api"
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
"docker",
|
"docker",
|
||||||
"ps",
|
"inspect",
|
||||||
"-a",
|
"-f",
|
||||||
"--format",
|
"{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}",
|
||||||
"{{.Names}}",
|
api_container,
|
||||||
"--filter",
|
|
||||||
f"name={instance_name.lower()}",
|
|
||||||
],
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
return result.stdout.strip().split("\n")[0]
|
networks = result.stdout.strip().split()
|
||||||
return None
|
for net in networks:
|
||||||
|
if "backend" in net.lower():
|
||||||
|
return net
|
||||||
|
# API container is on some network — return the first one
|
||||||
|
return networks[0]
|
||||||
|
return "backend"
|
||||||
|
|
||||||
|
|
||||||
def connect_container_to_network(
|
def connect_container_to_network(
|
||||||
container_name: str, network_name: str = "backend"
|
container_name: str, network_name: str | None = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Connect a Docker container to an existing network.
|
"""Connect a Docker container to an existing network.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
container_name: Name or ID of the container
|
container_name: Name or ID of the container
|
||||||
network_name: Name of the Docker network (default: backend)
|
network_name: Name of the Docker network. If None, auto-detects
|
||||||
|
from the API container's own network membership.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful, False otherwise
|
True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
|
if network_name is None:
|
||||||
|
network_name = get_backend_network_name()
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["docker", "network", "connect", network_name, container_name],
|
["docker", "network", "connect", network_name, container_name],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -210,6 +294,64 @@ def connect_container_to_network(
|
|||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_ip_on_network(
|
||||||
|
container_id: str, network_name: str | None = None
|
||||||
|
) -> str | None:
|
||||||
|
"""Get a container's IP address on a specific Docker network.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name.
|
||||||
|
network_name: Network name. If None, auto-detects from the API container.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
IP address string, or None if the container is not on that network.
|
||||||
|
"""
|
||||||
|
if network_name is None:
|
||||||
|
network_name = get_backend_network_name()
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
|
f"{{{{.NetworkSettings.Networks.{network_name}.IPAddress}}}}",
|
||||||
|
container_id,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
ip = result.stdout.strip()
|
||||||
|
if ip and ip != "<no value>":
|
||||||
|
return ip
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_container_on_network(container_id: str, network_name: str | None = None) -> bool:
|
||||||
|
"""Check whether a container is already attached to a Docker network.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name.
|
||||||
|
network_name: Network name. If None, auto-detects from the API container.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the container is on the network.
|
||||||
|
"""
|
||||||
|
if network_name is None:
|
||||||
|
network_name = get_backend_network_name()
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"-f",
|
||||||
|
f"{{{{.NetworkSettings.Networks.{network_name}}}}}",
|
||||||
|
container_id,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.returncode == 0 and "<no value>" not in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def get_container_status(container_id: str) -> dict[str, Any]:
|
def get_container_status(container_id: str) -> dict[str, Any]:
|
||||||
"""Get the status of a Docker container.
|
"""Get the status of a Docker container.
|
||||||
|
|
||||||
@@ -340,217 +482,3 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
|||||||
return port
|
return port
|
||||||
|
|
||||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||||
|
|
||||||
|
|
||||||
def start_cloudflared_tunnel(
|
|
||||||
container_name: str, port: int, timeout: int = 30
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Start a temporary Cloudflare tunnel for a container.
|
|
||||||
|
|
||||||
Uses 'cloudflared tunnel --url' to create a temporary tunnel
|
|
||||||
with a random trycloudflare.com URL.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
container_name: Name of the Docker container to tunnel to
|
|
||||||
port: Port number the container listens on
|
|
||||||
timeout: Maximum seconds to wait for tunnel URL
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
|
||||||
"""
|
|
||||||
import subprocess
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# First verify the container is accessible
|
|
||||||
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
|
||||||
for attempt in range(10):
|
|
||||||
check = subprocess.run(
|
|
||||||
[
|
|
||||||
"curl",
|
|
||||||
"-s",
|
|
||||||
"-o",
|
|
||||||
"/dev/null",
|
|
||||||
"-w",
|
|
||||||
"%{http_code}",
|
|
||||||
f"http://{container_name}:{port}",
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip()
|
|
||||||
)
|
|
||||||
if check.returncode == 0:
|
|
||||||
break
|
|
||||||
time.sleep(1)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"Container %s:%d not responding to curl checks", container_name, port
|
|
||||||
)
|
|
||||||
|
|
||||||
# Run cloudflared in background, capture output
|
|
||||||
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait for the URL to appear in output
|
|
||||||
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
|
||||||
start_time = time.time()
|
|
||||||
url = None
|
|
||||||
|
|
||||||
if proc.stdout is None:
|
|
||||||
proc.terminate()
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
raise RuntimeError("Failed to capture cloudflared output")
|
|
||||||
|
|
||||||
while time.time() - start_time < timeout:
|
|
||||||
# Read available output
|
|
||||||
import select
|
|
||||||
|
|
||||||
readable, _, _ = select.select([proc.stdout], [], [], 1.0)
|
|
||||||
if readable:
|
|
||||||
line = proc.stdout.readline()
|
|
||||||
if line:
|
|
||||||
match = url_pattern.search(line)
|
|
||||||
if match:
|
|
||||||
url = match.group(0)
|
|
||||||
break
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
proc.terminate()
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Failed to get tunnel URL within {timeout}s. "
|
|
||||||
f"cloudflared output may contain errors."
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"url": url, "pid": str(proc.pid)}
|
|
||||||
|
|
||||||
|
|
||||||
def stop_cloudflared_tunnel(pid: str) -> None:
|
|
||||||
"""Stop a cloudflared tunnel process.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pid: Process ID of the cloudflared tunnel
|
|
||||||
"""
|
|
||||||
import signal
|
|
||||||
|
|
||||||
try:
|
|
||||||
os.kill(int(pid), signal.SIGTERM)
|
|
||||||
except ProcessLookupError:
|
|
||||||
pass # Already stopped
|
|
||||||
|
|
||||||
|
|
||||||
def recreate_tunnel(
|
|
||||||
container_name: str, port: int, old_pid: str | None = None
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""Recreate a temporary Cloudflare tunnel.
|
|
||||||
|
|
||||||
Stops the old tunnel (if pid provided) and starts a new one.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
container_name: Name of the Docker container to tunnel to
|
|
||||||
port: Port number the container listens on
|
|
||||||
old_pid: Optional PID of the old tunnel process to stop
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with 'url' and 'pid' for the new tunnel
|
|
||||||
"""
|
|
||||||
if old_pid:
|
|
||||||
stop_cloudflared_tunnel(old_pid)
|
|
||||||
|
|
||||||
return start_cloudflared_tunnel(container_name, port)
|
|
||||||
|
|
||||||
|
|
||||||
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
|
||||||
"""Check if a tunnel URL is healthy with smart error classification.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: The tunnel URL to check
|
|
||||||
timeout: Request timeout in seconds
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with 'tunnel_status' (healthy, unreachable, error_response, not_applicable),
|
|
||||||
'status_code' (int or None), 'healthy' (bool), and 'error' (str or None)
|
|
||||||
"""
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
"curl",
|
|
||||||
"-s",
|
|
||||||
"-o",
|
|
||||||
"/dev/null",
|
|
||||||
"-w",
|
|
||||||
"%{http_code}",
|
|
||||||
"--max-time",
|
|
||||||
str(timeout),
|
|
||||||
url,
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=timeout + 5,
|
|
||||||
)
|
|
||||||
status_code = int(result.stdout.strip())
|
|
||||||
|
|
||||||
if 200 <= status_code < 400:
|
|
||||||
return {
|
|
||||||
"tunnel_status": "healthy",
|
|
||||||
"status_code": status_code,
|
|
||||||
"healthy": True,
|
|
||||||
"error": None,
|
|
||||||
}
|
|
||||||
elif status_code in (502, 503, 504):
|
|
||||||
# Application error, not tunnel error
|
|
||||||
return {
|
|
||||||
"tunnel_status": "error_response",
|
|
||||||
"status_code": status_code,
|
|
||||||
"healthy": False,
|
|
||||||
"error": f"Application returned HTTP {status_code}",
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"tunnel_status": "error_response",
|
|
||||||
"status_code": status_code,
|
|
||||||
"healthy": False,
|
|
||||||
"error": f"HTTP {status_code}",
|
|
||||||
}
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return {
|
|
||||||
"tunnel_status": "unreachable",
|
|
||||||
"status_code": None,
|
|
||||||
"healthy": False,
|
|
||||||
"error": "Tunnel request timed out",
|
|
||||||
}
|
|
||||||
except (ValueError, Exception) as e:
|
|
||||||
error_str = str(e).lower()
|
|
||||||
# Classify connection errors
|
|
||||||
if any(
|
|
||||||
err in error_str
|
|
||||||
for err in [
|
|
||||||
"connection refused",
|
|
||||||
"econnrefused",
|
|
||||||
"could not resolve",
|
|
||||||
"nodename",
|
|
||||||
]
|
|
||||||
):
|
|
||||||
return {
|
|
||||||
"tunnel_status": "unreachable",
|
|
||||||
"status_code": None,
|
|
||||||
"healthy": False,
|
|
||||||
"error": f"Tunnel unreachable: {e}",
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"tunnel_status": "unreachable",
|
|
||||||
"status_code": None,
|
|
||||||
"healthy": False,
|
|
||||||
"error": str(e),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import subprocess
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dict | None = None) -> tuple[int, str, str]:
|
def build_image(
|
||||||
|
instance_dir: str, dockerfile: str, tag: str, build_context: dict | None = None
|
||||||
|
) -> tuple[int, str, str]:
|
||||||
"""Build a Docker image from a Dockerfile.
|
"""Build a Docker image from a Dockerfile.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -20,10 +22,16 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
"""
|
"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Defensive: normalise any CRLF that may have crept in from manifest DB
|
||||||
|
# strings — Docker's legacy builder treats \r as a character after the
|
||||||
|
# backslash, breaking RUN continuations and producing
|
||||||
|
# "unknown instruction" errors.
|
||||||
|
dockerfile = dockerfile.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
|
||||||
# Write Dockerfile
|
# Write Dockerfile
|
||||||
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
dockerfile_path = Path(instance_dir) / "Dockerfile"
|
||||||
dockerfile_path.write_text(dockerfile)
|
dockerfile_path.write_text(dockerfile, newline="\n")
|
||||||
logger.debug("Wrote Dockerfile to %s", dockerfile_path)
|
logger.debug("Wrote Dockerfile to %s (%d bytes)", dockerfile_path, len(dockerfile))
|
||||||
|
|
||||||
# Write build context files
|
# Write build context files
|
||||||
if build_context:
|
if build_context:
|
||||||
@@ -33,19 +41,27 @@ def build_image(instance_dir: str, dockerfile: str, tag: str, build_context: dic
|
|||||||
try:
|
try:
|
||||||
full_path.resolve().relative_to(Path(instance_dir).resolve())
|
full_path.resolve().relative_to(Path(instance_dir).resolve())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.error("Build context file path escapes instance directory: %s", file_path)
|
logger.error(
|
||||||
raise ValueError(f"Build context file path '{file_path}' escapes instance directory")
|
"Build context file path escapes instance directory: %s", file_path
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
f"Build context file path '{file_path}' escapes instance directory"
|
||||||
|
)
|
||||||
|
|
||||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
full_path.write_text(content)
|
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
full_path.write_text(normalized, newline="\n")
|
||||||
logger.debug("Wrote build context file: %s", full_path)
|
logger.debug("Wrote build context file: %s", full_path)
|
||||||
|
|
||||||
# Build image
|
# Build image
|
||||||
logger.debug("Building Docker image with tag: %s", tag)
|
logger.debug("Building Docker image with tag: %s", tag)
|
||||||
cmd = [
|
cmd = [
|
||||||
"docker", "build",
|
"docker",
|
||||||
"-t", tag,
|
"build",
|
||||||
"-f", str(dockerfile_path),
|
"-t",
|
||||||
|
tag,
|
||||||
|
"-f",
|
||||||
|
str(dockerfile_path),
|
||||||
instance_dir,
|
instance_dir,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""In-memory typed event bus for instance lifecycle and health events."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
InstanceEventPayload = dict[str, Any]
|
||||||
|
EventCallback = Callable[[InstanceEventPayload], Awaitable[None] | None] # noqa: UP044
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceEventBus:
|
||||||
|
"""Singleton in-memory event bus with typed pub/sub and exception isolation."""
|
||||||
|
|
||||||
|
_instance: "InstanceEventBus | None" = None
|
||||||
|
_lock: asyncio.Lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._subscribers: dict[str, list[tuple[str, EventCallback]]] = {}
|
||||||
|
|
||||||
|
def __new__(cls) -> "InstanceEventBus":
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._subscribers = {}
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def _reset_for_testing(self) -> None:
|
||||||
|
"""Clear all subscribers. For test use only."""
|
||||||
|
self._subscribers.clear()
|
||||||
|
|
||||||
|
def subscribe(
|
||||||
|
self,
|
||||||
|
event_type: str,
|
||||||
|
callback: EventCallback,
|
||||||
|
) -> Callable[[], None]:
|
||||||
|
"""Register a callback for an event type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_type: The event type to subscribe to.
|
||||||
|
callback: A sync or async callable that receives the payload.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An unsubscribe function.
|
||||||
|
"""
|
||||||
|
if event_type not in self._subscribers:
|
||||||
|
self._subscribers[event_type] = []
|
||||||
|
callback_id = str(uuid.uuid4())
|
||||||
|
self._subscribers[event_type].append((callback_id, callback))
|
||||||
|
|
||||||
|
def unsubscribe() -> None:
|
||||||
|
self.unsubscribe(event_type, callback_id)
|
||||||
|
|
||||||
|
return unsubscribe
|
||||||
|
|
||||||
|
def unsubscribe(self, event_type: str, callback_id: str) -> None:
|
||||||
|
"""Remove a specific callback by ID."""
|
||||||
|
if event_type in self._subscribers:
|
||||||
|
self._subscribers[event_type] = [
|
||||||
|
(cid, cb)
|
||||||
|
for cid, cb in self._subscribers[event_type]
|
||||||
|
if cid != callback_id
|
||||||
|
]
|
||||||
|
if not self._subscribers[event_type]:
|
||||||
|
del self._subscribers[event_type]
|
||||||
|
|
||||||
|
def unsubscribe_all(self, event_type: str) -> None:
|
||||||
|
"""Remove all subscribers for an event type."""
|
||||||
|
self._subscribers.pop(event_type, None)
|
||||||
|
|
||||||
|
async def publish(self, event_type: str, payload: InstanceEventPayload) -> None:
|
||||||
|
"""Deliver payload to all subscribers of event_type.
|
||||||
|
|
||||||
|
Also delivers to subscribers registered under the wildcard "*".
|
||||||
|
Exceptions from individual subscribers are caught and logged;
|
||||||
|
delivery continues to remaining subscribers.
|
||||||
|
"""
|
||||||
|
callbacks: list[tuple[str, EventCallback]] = []
|
||||||
|
callbacks.extend(self._subscribers.get(event_type, []))
|
||||||
|
callbacks.extend(self._subscribers.get("*", []))
|
||||||
|
|
||||||
|
for _callback_id, callback in callbacks:
|
||||||
|
try:
|
||||||
|
if inspect.iscoroutinefunction(callback):
|
||||||
|
await callback(payload)
|
||||||
|
else:
|
||||||
|
callback(payload)
|
||||||
|
except Exception:
|
||||||
|
correlation_id = payload.get("correlation_id", "unknown")
|
||||||
|
logger.exception(
|
||||||
|
"Event subscriber failed for %s",
|
||||||
|
event_type,
|
||||||
|
extra={"correlation_id": correlation_id},
|
||||||
|
)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""File operations scoped to a workspace directory."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileEntry:
|
||||||
|
"""A single file or directory entry."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
type: str # "file" or "directory"
|
||||||
|
size: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileService:
|
||||||
|
"""Read and write files within a workspace directory."""
|
||||||
|
|
||||||
|
def list_directory(
|
||||||
|
self,
|
||||||
|
workspace: Workspace,
|
||||||
|
relative_path: str = "",
|
||||||
|
) -> list[FileEntry]:
|
||||||
|
"""List entries in a workspace directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace: The workspace to list files in.
|
||||||
|
relative_path: Path relative to workspace root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of file entries sorted by name (directories first).
|
||||||
|
"""
|
||||||
|
abs_path = os.path.join(workspace.path, relative_path)
|
||||||
|
abs_path = os.path.normpath(abs_path)
|
||||||
|
|
||||||
|
# Security: ensure we stay within workspace
|
||||||
|
if not abs_path.startswith(os.path.normpath(workspace.path)):
|
||||||
|
raise ValueError("Path escapes workspace directory")
|
||||||
|
|
||||||
|
if not os.path.exists(abs_path):
|
||||||
|
return []
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for item in sorted(os.listdir(abs_path)):
|
||||||
|
full = os.path.join(abs_path, item)
|
||||||
|
rel = os.path.join(relative_path, item) if relative_path else item
|
||||||
|
is_dir = os.path.isdir(full)
|
||||||
|
size = os.path.getsize(full) if os.path.isfile(full) else None
|
||||||
|
entries.append(
|
||||||
|
FileEntry(
|
||||||
|
name=item,
|
||||||
|
path=rel.replace("\\", "/"),
|
||||||
|
type="directory" if is_dir else "file",
|
||||||
|
size=size,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Directories first, then files, both alphabetical
|
||||||
|
entries.sort(key=lambda e: (0 if e.type == "directory" else 1, e.name.lower()))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def read_file(self, workspace: Workspace, relative_path: str) -> str:
|
||||||
|
"""Read a text file from the workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace: The workspace to read from.
|
||||||
|
relative_path: Path relative to workspace root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File contents as string.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If path escapes workspace or file is binary.
|
||||||
|
FileNotFoundError: If file does not exist.
|
||||||
|
"""
|
||||||
|
abs_path = self._resolve_path(workspace, relative_path)
|
||||||
|
|
||||||
|
if not os.path.isfile(abs_path):
|
||||||
|
raise FileNotFoundError(f"Not a file: {relative_path}")
|
||||||
|
|
||||||
|
# Basic binary check — read first 8KB and look for null bytes
|
||||||
|
with open(abs_path, "rb") as f:
|
||||||
|
chunk = f.read(8192)
|
||||||
|
if b"\x00" in chunk:
|
||||||
|
raise ValueError("Binary files cannot be viewed")
|
||||||
|
|
||||||
|
with open(abs_path, encoding="utf-8", errors="replace") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def write_file(
|
||||||
|
self,
|
||||||
|
workspace: Workspace,
|
||||||
|
relative_path: str,
|
||||||
|
content: str,
|
||||||
|
) -> None:
|
||||||
|
"""Write a text file to the workspace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace: The workspace to write to.
|
||||||
|
relative_path: Path relative to workspace root.
|
||||||
|
content: File contents.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If path escapes workspace.
|
||||||
|
"""
|
||||||
|
abs_path = self._resolve_path(workspace, relative_path)
|
||||||
|
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||||
|
|
||||||
|
with open(abs_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
logger.info("Wrote file %s in workspace %s", relative_path, workspace.id)
|
||||||
|
|
||||||
|
def _resolve_path(self, workspace: Workspace, relative_path: str) -> str:
|
||||||
|
"""Resolve a relative path to absolute, with security check."""
|
||||||
|
abs_path = os.path.normpath(os.path.join(workspace.path, relative_path))
|
||||||
|
workspace_root = os.path.normpath(workspace.path)
|
||||||
|
|
||||||
|
if not abs_path.startswith(workspace_root):
|
||||||
|
raise ValueError("Path escapes workspace directory")
|
||||||
|
|
||||||
|
return abs_path
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Git commands scoped to a workspace directory."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GitStatus:
|
||||||
|
"""Parsed git status output."""
|
||||||
|
|
||||||
|
branch: str
|
||||||
|
modified: list[str]
|
||||||
|
added: list[str]
|
||||||
|
deleted: list[str]
|
||||||
|
untracked: list[str]
|
||||||
|
ahead: int = 0
|
||||||
|
behind: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Commit:
|
||||||
|
"""A single git commit."""
|
||||||
|
|
||||||
|
hash: str
|
||||||
|
message: str
|
||||||
|
author: str
|
||||||
|
date: str
|
||||||
|
|
||||||
|
|
||||||
|
class GitOperations:
|
||||||
|
"""Run git commands within a workspace directory."""
|
||||||
|
|
||||||
|
def __init__(self, workspace: Workspace) -> None:
|
||||||
|
self.cwd = workspace.path
|
||||||
|
self.branch = workspace.branch
|
||||||
|
|
||||||
|
async def _run(self, *cmd: str) -> tuple[int, str, str]:
|
||||||
|
"""Run a git command and return (returncode, stdout, stderr)."""
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
return proc.returncode or 0, stdout.decode(), stderr.decode()
|
||||||
|
|
||||||
|
async def status(self) -> GitStatus:
|
||||||
|
"""Get git status for the workspace."""
|
||||||
|
returncode, stdout, _ = await self._run(
|
||||||
|
"git", "-C", self.cwd, "status", "--porcelain", "-b"
|
||||||
|
)
|
||||||
|
|
||||||
|
modified: list[str] = []
|
||||||
|
added: list[str] = []
|
||||||
|
deleted: list[str] = []
|
||||||
|
untracked: list[str] = []
|
||||||
|
branch = self.branch
|
||||||
|
ahead = 0
|
||||||
|
behind = 0
|
||||||
|
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
if line.startswith("##"):
|
||||||
|
# Branch info line
|
||||||
|
branch_info = line[3:].strip()
|
||||||
|
if "..." in branch_info:
|
||||||
|
branch = branch_info.split("...")[0]
|
||||||
|
if "[ahead " in branch_info:
|
||||||
|
ahead_str = branch_info.split("[ahead ")[1].split("]")[0]
|
||||||
|
ahead = int(ahead_str.split(",")[0])
|
||||||
|
if "[behind " in branch_info:
|
||||||
|
behind_str = branch_info.split("[behind ")[1].split("]")[0]
|
||||||
|
behind = int(behind_str.split(",")[0])
|
||||||
|
else:
|
||||||
|
branch = branch_info
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(line) < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
status_code = line[:2]
|
||||||
|
file_path = line[3:]
|
||||||
|
|
||||||
|
# XY format: X = index status, Y = working tree status
|
||||||
|
if status_code == "??":
|
||||||
|
untracked.append(file_path)
|
||||||
|
elif status_code[1] == "D" or status_code[0] == "D":
|
||||||
|
deleted.append(file_path)
|
||||||
|
elif status_code[0] == "A" or status_code[1] == "A":
|
||||||
|
added.append(file_path)
|
||||||
|
else:
|
||||||
|
modified.append(file_path)
|
||||||
|
|
||||||
|
return GitStatus(
|
||||||
|
branch=branch,
|
||||||
|
modified=modified,
|
||||||
|
added=added,
|
||||||
|
deleted=deleted,
|
||||||
|
untracked=untracked,
|
||||||
|
ahead=ahead,
|
||||||
|
behind=behind,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def commit(self, message: str) -> None:
|
||||||
|
"""Stage all changes and commit."""
|
||||||
|
rc, _, err = await self._run("git", "-C", self.cwd, "add", "-A")
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"Git add failed: {err}")
|
||||||
|
|
||||||
|
rc, _, err = await self._run("git", "-C", self.cwd, "commit", "-m", message)
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"Git commit failed: {err}")
|
||||||
|
|
||||||
|
logger.info("Committed in workspace: %s", self.cwd)
|
||||||
|
|
||||||
|
async def push(self) -> None:
|
||||||
|
"""Push current branch to origin."""
|
||||||
|
rc, _, err = await self._run(
|
||||||
|
"git", "-C", self.cwd, "push", "origin", self.branch
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"Git push failed: {err}")
|
||||||
|
|
||||||
|
logger.info("Pushed branch %s from workspace: %s", self.branch, self.cwd)
|
||||||
|
|
||||||
|
async def pull(self) -> None:
|
||||||
|
"""Pull current branch from origin."""
|
||||||
|
rc, _, err = await self._run(
|
||||||
|
"git", "-C", self.cwd, "pull", "origin", self.branch
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"Git pull failed: {err}")
|
||||||
|
|
||||||
|
logger.info("Pulled branch %s in workspace: %s", self.branch, self.cwd)
|
||||||
|
|
||||||
|
async def fetch(self) -> None:
|
||||||
|
"""Fetch from origin."""
|
||||||
|
rc, _, err = await self._run("git", "-C", self.cwd, "fetch", "origin")
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"Git fetch failed: {err}")
|
||||||
|
|
||||||
|
logger.info("Fetched origin for workspace: %s", self.cwd)
|
||||||
|
|
||||||
|
async def checkout(self, branch: str) -> None:
|
||||||
|
"""Checkout a branch."""
|
||||||
|
rc, _, err = await self._run("git", "-C", self.cwd, "checkout", branch)
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"Git checkout failed: {err}")
|
||||||
|
|
||||||
|
self.branch = branch
|
||||||
|
logger.info("Checked out branch %s in workspace: %s", branch, self.cwd)
|
||||||
|
|
||||||
|
async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]:
|
||||||
|
"""Get commit history.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Optional file path to filter history.
|
||||||
|
limit: Maximum number of commits.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of commits.
|
||||||
|
"""
|
||||||
|
cmd = [
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
self.cwd,
|
||||||
|
"log",
|
||||||
|
f"--max-count={limit}",
|
||||||
|
"--pretty=format:%H|%s|%an|%ad",
|
||||||
|
"--date=iso",
|
||||||
|
]
|
||||||
|
if path:
|
||||||
|
cmd.extend(["--", path])
|
||||||
|
|
||||||
|
rc, stdout, err = await self._run(*cmd)
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"Git log failed: {err}")
|
||||||
|
|
||||||
|
commits = []
|
||||||
|
for line in stdout.strip().splitlines():
|
||||||
|
parts = line.split("|", 3)
|
||||||
|
if len(parts) >= 4:
|
||||||
|
commits.append(
|
||||||
|
Commit(
|
||||||
|
hash=parts[0],
|
||||||
|
message=parts[1],
|
||||||
|
author=parts[2],
|
||||||
|
date=parts[3],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return commits
|
||||||
|
|
||||||
|
async def branches(self) -> tuple[list[str], str]:
|
||||||
|
"""List all branches and current branch.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (all_branches, current_branch).
|
||||||
|
"""
|
||||||
|
rc, stdout, err = await self._run(
|
||||||
|
"git", "-C", self.cwd, "branch", "-a", "--format=%(refname:short)"
|
||||||
|
)
|
||||||
|
if rc != 0:
|
||||||
|
raise RuntimeError(f"Git branch failed: {err}")
|
||||||
|
|
||||||
|
branches = []
|
||||||
|
current = self.branch
|
||||||
|
for line in stdout.strip().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("HEAD") or line.endswith("/HEAD"):
|
||||||
|
continue
|
||||||
|
if line.startswith("remotes/origin/"):
|
||||||
|
branch_name = line.replace("remotes/origin/", "")
|
||||||
|
if branch_name not in branches:
|
||||||
|
branches.append(branch_name)
|
||||||
|
elif line and line not in branches:
|
||||||
|
branches.append(line)
|
||||||
|
|
||||||
|
return branches, current
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Git operations for workspace management."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GitService:
|
||||||
|
"""Low-level git operations for creating and syncing workspaces."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _prepare_ssh_env(
|
||||||
|
ssh_key: str | None,
|
||||||
|
) -> tuple[dict[str, str] | None, str | None]:
|
||||||
|
"""Prepare environment for git commands with SSH authentication.
|
||||||
|
|
||||||
|
Returns a tuple of (env_dict, temp_key_path). Caller must clean up key_path.
|
||||||
|
"""
|
||||||
|
if not ssh_key:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
fd, key_path = tempfile.mkstemp(prefix="ssh_key_")
|
||||||
|
try:
|
||||||
|
os.write(fd, ssh_key.encode())
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
os.chmod(key_path, 0o600)
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"GIT_SSH_COMMAND": f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||||
|
}
|
||||||
|
return env, key_path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def clone(
|
||||||
|
remote_url: str, branch: str, path: str, ssh_key: str | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Clone a repository to the given path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
remote_url: The git remote URL.
|
||||||
|
branch: The branch to clone.
|
||||||
|
path: The destination path for the clone.
|
||||||
|
ssh_key: Optional decrypted SSH private key for authentication.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If the clone fails.
|
||||||
|
"""
|
||||||
|
cmd = [
|
||||||
|
"git",
|
||||||
|
"clone",
|
||||||
|
"--branch",
|
||||||
|
branch,
|
||||||
|
"--single-branch",
|
||||||
|
remote_url,
|
||||||
|
path,
|
||||||
|
]
|
||||||
|
|
||||||
|
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||||
|
logger.error("Git clone failed: %s", error_msg)
|
||||||
|
raise RuntimeError(f"Git clone failed: {error_msg}")
|
||||||
|
logger.debug("Cloned %s (branch: %s) to %s", remote_url, branch, path)
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def fetch(path: str, ssh_key: str | None = None) -> None:
|
||||||
|
"""Fetch from origin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: The path to the local git repository.
|
||||||
|
ssh_key: Optional decrypted SSH private key for authentication.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If fetch fails.
|
||||||
|
"""
|
||||||
|
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
path,
|
||||||
|
"fetch",
|
||||||
|
"origin",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||||
|
logger.error("Git fetch failed: %s", error_msg)
|
||||||
|
raise RuntimeError(f"Git fetch failed: {error_msg}")
|
||||||
|
logger.debug("Fetched origin for %s", path)
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def pull(path: str, branch: str, ssh_key: str | None = None) -> None:
|
||||||
|
"""Pull latest changes from origin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: The path to the local git repository.
|
||||||
|
branch: The branch to pull.
|
||||||
|
ssh_key: Optional decrypted SSH private key for authentication.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If pull fails.
|
||||||
|
"""
|
||||||
|
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
path,
|
||||||
|
"pull",
|
||||||
|
"origin",
|
||||||
|
branch,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
error_msg = stderr.decode().strip() if stderr else "unknown error"
|
||||||
|
logger.error("Git pull failed: %s", error_msg)
|
||||||
|
raise RuntimeError(f"Git pull failed: {error_msg}")
|
||||||
|
logger.debug("Pulled origin/%s for %s", branch, path)
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def branch_exists_remotely(
|
||||||
|
path: str, branch: str, ssh_key: str | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a branch exists on the remote.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: The path to the local git repository.
|
||||||
|
branch: The branch name to check.
|
||||||
|
ssh_key: Optional decrypted SSH private key for authentication.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the branch exists on origin, False otherwise.
|
||||||
|
"""
|
||||||
|
env, key_path = GitService._prepare_ssh_env(ssh_key)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", path, "ls-remote", "--heads", "origin", branch],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env={**os.environ, **env} if env else None,
|
||||||
|
)
|
||||||
|
exists = result.returncode == 0 and result.stdout.strip() != ""
|
||||||
|
logger.debug("Branch %s exists on remote: %s", branch, exists)
|
||||||
|
return exists
|
||||||
|
finally:
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
os.unlink(key_path)
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Background health monitor that polls container and tunnel health."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.database import SessionLocal
|
||||||
|
from src.models.health_check import HealthCheck
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.services.correlation import get_correlation_id
|
||||||
|
from src.services.docker import get_container_status
|
||||||
|
from src.services.tunnel import check_tunnel_health
|
||||||
|
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
|
from src.services.notification_service import notification_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthSnapshot:
|
||||||
|
"""In-memory snapshot of an instance's health state."""
|
||||||
|
|
||||||
|
container_status: str | None = None
|
||||||
|
container_healthy: bool | None = None
|
||||||
|
tunnel_healthy: bool | None = None
|
||||||
|
exit_code: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HealthMonitor:
|
||||||
|
"""Polls container and tunnel health, publishing events on state changes."""
|
||||||
|
|
||||||
|
POLL_INTERVAL_SECONDS: float = 15.0
|
||||||
|
_MONITORED_STATUSES: set[str] = {"starting", "running", "unhealthy"}
|
||||||
|
|
||||||
|
def __init__(self, event_bus: InstanceEventBus) -> None:
|
||||||
|
self._event_bus = event_bus
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self._last_known_state: dict[uuid.UUID, HealthSnapshot] = {}
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Idempotent start of the background polling task."""
|
||||||
|
if self._task is not None and not self._task.done():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._task = loop.create_task(self._poll_loop())
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Cancel the background task and clear state."""
|
||||||
|
if self._task is not None and not self._task.done():
|
||||||
|
self._task.cancel()
|
||||||
|
self._last_known_state.clear()
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
async def _poll_loop(self) -> None:
|
||||||
|
"""Main polling loop."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(self.POLL_INTERVAL_SECONDS)
|
||||||
|
await self._run_check_cycle()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Health monitor poll loop error")
|
||||||
|
|
||||||
|
async def _run_check_cycle(self) -> None:
|
||||||
|
"""Check all monitored instances in one cycle."""
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance).where(
|
||||||
|
ToolInstance.status.in_(self._MONITORED_STATUSES)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
instances = result.scalars().all()
|
||||||
|
|
||||||
|
for instance in instances:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
await self._check_instance(session, instance)
|
||||||
|
|
||||||
|
async def _check_instance(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: ToolInstance,
|
||||||
|
) -> None:
|
||||||
|
"""Check a single instance and handle state transitions."""
|
||||||
|
try:
|
||||||
|
container_info = get_container_status(instance.container_id or "")
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Health check failed for instance %s",
|
||||||
|
instance.id,
|
||||||
|
extra={
|
||||||
|
"instance_id": str(instance.id),
|
||||||
|
"correlation_id": get_correlation_id(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
container_status = container_info["status"]
|
||||||
|
exit_code = container_info["exit_code"]
|
||||||
|
container_healthy = (
|
||||||
|
container_info["health"] == "healthy" if container_info["health"] else None
|
||||||
|
)
|
||||||
|
|
||||||
|
tunnel_healthy: bool | None = None
|
||||||
|
if instance.public_url and container_status == "running":
|
||||||
|
try:
|
||||||
|
tunnel_result = check_tunnel_health(instance.public_url)
|
||||||
|
tunnel_healthy = tunnel_result.get("healthy", False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Tunnel health check failed for instance %s",
|
||||||
|
instance.id,
|
||||||
|
extra={
|
||||||
|
"instance_id": str(instance.id),
|
||||||
|
"correlation_id": get_correlation_id(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tunnel_healthy = False
|
||||||
|
|
||||||
|
snapshot = HealthSnapshot(
|
||||||
|
container_status=container_status,
|
||||||
|
container_healthy=container_healthy,
|
||||||
|
tunnel_healthy=tunnel_healthy,
|
||||||
|
exit_code=exit_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
previous = self._last_known_state.get(instance.id)
|
||||||
|
|
||||||
|
# Determine new status
|
||||||
|
new_status = self._derive_status(snapshot)
|
||||||
|
|
||||||
|
# If first check or state changed
|
||||||
|
if previous is None or not self._snapshots_equal(previous, snapshot):
|
||||||
|
await self._handle_state_change(
|
||||||
|
session, instance, previous, snapshot, new_status
|
||||||
|
)
|
||||||
|
self._last_known_state[instance.id] = snapshot
|
||||||
|
|
||||||
|
def _derive_status(self, snapshot: HealthSnapshot) -> str:
|
||||||
|
"""Derive instance status from health snapshot."""
|
||||||
|
if snapshot.container_status != "running":
|
||||||
|
return "error"
|
||||||
|
if snapshot.tunnel_healthy is False:
|
||||||
|
return "unhealthy"
|
||||||
|
return "running"
|
||||||
|
|
||||||
|
def _snapshots_equal(self, a: HealthSnapshot, b: HealthSnapshot) -> bool:
|
||||||
|
"""Compare two snapshots for equality."""
|
||||||
|
return (
|
||||||
|
a.container_status == b.container_status
|
||||||
|
and a.container_healthy == b.container_healthy
|
||||||
|
and a.tunnel_healthy == b.tunnel_healthy
|
||||||
|
and a.exit_code == b.exit_code
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_state_change(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: ToolInstance,
|
||||||
|
previous: HealthSnapshot | None,
|
||||||
|
snapshot: HealthSnapshot,
|
||||||
|
new_status: str,
|
||||||
|
) -> None:
|
||||||
|
"""Update DB, insert health check, and publish event."""
|
||||||
|
previous_status = instance.status
|
||||||
|
|
||||||
|
# Update instance status
|
||||||
|
instance.status = new_status
|
||||||
|
if new_status == "error":
|
||||||
|
instance.last_stopped_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Insert health check row
|
||||||
|
health_check = HealthCheck(
|
||||||
|
instance_id=instance.id,
|
||||||
|
container_status=snapshot.container_status,
|
||||||
|
container_healthy=snapshot.container_healthy,
|
||||||
|
tunnel_healthy=snapshot.tunnel_healthy,
|
||||||
|
exit_code=snapshot.exit_code,
|
||||||
|
probe_status=None,
|
||||||
|
probe_output=None,
|
||||||
|
)
|
||||||
|
session.add(health_check)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Build event payload
|
||||||
|
correlation_id = get_correlation_id()
|
||||||
|
metadata: dict = {"previous_status": previous_status}
|
||||||
|
if snapshot.exit_code is not None:
|
||||||
|
metadata["exit_code"] = snapshot.exit_code
|
||||||
|
metadata["error_type"] = "container"
|
||||||
|
if instance.public_url:
|
||||||
|
metadata["tunnel_url"] = instance.public_url
|
||||||
|
|
||||||
|
if new_status == "error":
|
||||||
|
event_type = "instance.error"
|
||||||
|
message = f"Container failed with status {snapshot.container_status}"
|
||||||
|
if snapshot.exit_code is not None:
|
||||||
|
message += f" (exit code: {snapshot.exit_code})"
|
||||||
|
else:
|
||||||
|
event_type = "instance.health_changed"
|
||||||
|
message = f"Container is now {new_status}"
|
||||||
|
|
||||||
|
payload: InstanceEventPayload = {
|
||||||
|
"event": event_type,
|
||||||
|
"instance_id": str(instance.id),
|
||||||
|
"status": new_status,
|
||||||
|
"message": message,
|
||||||
|
"metadata": metadata,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
await self._event_bus.publish(event_type, payload)
|
||||||
|
|
||||||
|
# Create notification for instance owner (fire-and-forget)
|
||||||
|
# Only send warnings and errors; skip "recovered" info notifications.
|
||||||
|
if new_status == "error":
|
||||||
|
category = "instance"
|
||||||
|
severity = "error"
|
||||||
|
title = "Container failed"
|
||||||
|
elif new_status == "unhealthy":
|
||||||
|
category = "health"
|
||||||
|
severity = "warning"
|
||||||
|
title = "Container unhealthy"
|
||||||
|
else:
|
||||||
|
# Running/recovered — do not notify
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await notification_service.create_notification(
|
||||||
|
session=session,
|
||||||
|
user_id=instance.owner_id,
|
||||||
|
category=category,
|
||||||
|
severity=severity,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
source_type="tool_instances",
|
||||||
|
source_id=instance.id,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to create notification for health event %s",
|
||||||
|
event_type,
|
||||||
|
extra={"correlation_id": correlation_id},
|
||||||
|
)
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Lifecycle hook helpers for instrumenting tool instance transitions."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.instance_event import InstanceEvent
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.services.correlation import get_correlation_id
|
||||||
|
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
|
from src.services.notification_service import notification_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_title(event_type: str) -> str:
|
||||||
|
"""Map lifecycle event type to a human-readable notification title."""
|
||||||
|
mapping = {
|
||||||
|
"instance.created": "Container created",
|
||||||
|
"instance.started": "Container started",
|
||||||
|
"instance.stopped": "Container stopped",
|
||||||
|
"instance.restarted": "Container restarted",
|
||||||
|
"instance.deleted": "Container deleted",
|
||||||
|
"instance.error": "Container error",
|
||||||
|
"instance.health_changed": "Container ready",
|
||||||
|
}
|
||||||
|
return mapping.get(
|
||||||
|
event_type,
|
||||||
|
event_type.replace("instance.", "").replace("_", " ").title(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_notify(event_type: str, status: str | None) -> bool:
|
||||||
|
"""Determine whether a lifecycle event should generate a notification.
|
||||||
|
|
||||||
|
Only warnings, errors, and "container is ready" (health_changed running)
|
||||||
|
are sent to users.
|
||||||
|
"""
|
||||||
|
if event_type == "instance.error":
|
||||||
|
return True
|
||||||
|
if event_type == "instance.health_changed" and status == "running":
|
||||||
|
return True
|
||||||
|
# Filter out: created, started, stopped, restarted, deleted, and any
|
||||||
|
# health_changed that is not "running" (unhealthy is handled by health_monitor)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _build_payload(
|
||||||
|
event_type: str,
|
||||||
|
instance: ToolInstance,
|
||||||
|
status: str | None = None,
|
||||||
|
message: str | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> InstanceEventPayload:
|
||||||
|
"""Construct a standard event payload."""
|
||||||
|
return {
|
||||||
|
"event": event_type,
|
||||||
|
"instance_id": str(instance.id),
|
||||||
|
"status": status or instance.status,
|
||||||
|
"message": message,
|
||||||
|
"metadata": metadata or {},
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"correlation_id": get_correlation_id(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_audit_row(
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: ToolInstance,
|
||||||
|
event_type: str,
|
||||||
|
created_by: uuid.UUID | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
message: str | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> InstanceEvent:
|
||||||
|
"""Persist an instance_events audit row."""
|
||||||
|
row = InstanceEvent(
|
||||||
|
instance_id=instance.id,
|
||||||
|
event_type=event_type.replace("instance.", ""),
|
||||||
|
status=status or instance.status,
|
||||||
|
message=message,
|
||||||
|
created_by=created_by,
|
||||||
|
event_metadata=metadata or {},
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
await session.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def publish_lifecycle_event(
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: ToolInstance,
|
||||||
|
event_type: str,
|
||||||
|
created_by: uuid.UUID | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
message: str | None = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Publish a lifecycle event and write an audit row after DB commit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_bus: The global event bus.
|
||||||
|
session: Active async DB session.
|
||||||
|
instance: The affected tool instance.
|
||||||
|
event_type: One of instance.created, instance.started, etc.
|
||||||
|
created_by: User ID for user-initiated actions; None for system.
|
||||||
|
status: Optional status override.
|
||||||
|
message: Optional human-readable message.
|
||||||
|
metadata: Optional extra metadata.
|
||||||
|
"""
|
||||||
|
payload = _build_payload(
|
||||||
|
event_type=event_type,
|
||||||
|
instance=instance,
|
||||||
|
status=status,
|
||||||
|
message=message,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write audit row
|
||||||
|
await _write_audit_row(
|
||||||
|
session=session,
|
||||||
|
instance=instance,
|
||||||
|
event_type=event_type,
|
||||||
|
created_by=created_by,
|
||||||
|
status=status or instance.status,
|
||||||
|
message=message,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Publish to bus
|
||||||
|
await event_bus.publish(event_type, payload)
|
||||||
|
|
||||||
|
# Create notification for instance owner (fire-and-forget)
|
||||||
|
# Only send warnings, errors, and "container is ready" notifications.
|
||||||
|
effective_status = status or instance.status
|
||||||
|
if not _should_notify(event_type, effective_status):
|
||||||
|
return
|
||||||
|
|
||||||
|
severity = "error" if event_type == "instance.error" else "success"
|
||||||
|
title = _derive_title(event_type)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await notification_service.create_notification(
|
||||||
|
session=session,
|
||||||
|
user_id=instance.owner_id,
|
||||||
|
category="instance",
|
||||||
|
severity=severity,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
source_type="tool_instances",
|
||||||
|
source_id=instance.id,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to create notification for lifecycle event %s",
|
||||||
|
event_type,
|
||||||
|
extra={"correlation_id": payload.get("correlation_id", "unknown")},
|
||||||
|
)
|
||||||
@@ -8,6 +8,8 @@ from typing import Any
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
from src.services.docker import sort_volumes_by_specificity
|
||||||
|
|
||||||
|
|
||||||
def resolve_base(manifest: dict) -> dict:
|
def resolve_base(manifest: dict) -> dict:
|
||||||
"""Merge a base definition into a tool manifest.
|
"""Merge a base definition into a tool manifest.
|
||||||
@@ -24,7 +26,7 @@ def resolve_base(manifest: dict) -> dict:
|
|||||||
result = deepcopy(manifest)
|
result = deepcopy(manifest)
|
||||||
|
|
||||||
base_definition_id = result.pop("base_definition_id", None)
|
base_definition_id = result.pop("base_definition_id", None)
|
||||||
base_version = result.pop("base_version", "latest")
|
result.pop("base_version", None)
|
||||||
|
|
||||||
if base_definition_id:
|
if base_definition_id:
|
||||||
# This will be provided by the caller (they have the DB session)
|
# This will be provided by the caller (they have the DB session)
|
||||||
@@ -116,11 +118,16 @@ def compile_dockerfile(manifest: dict) -> str:
|
|||||||
|
|
||||||
# System packages (apt)
|
# System packages (apt)
|
||||||
apt_packages = manifest.get("packages", {}).get("apt", [])
|
apt_packages = manifest.get("packages", {}).get("apt", [])
|
||||||
|
if manifest.get("user"):
|
||||||
|
# Ensure sudo is available for permission-fixing startup scripts
|
||||||
|
apt_packages = list(apt_packages)
|
||||||
|
if "sudo" not in apt_packages:
|
||||||
|
apt_packages.append("sudo")
|
||||||
if apt_packages:
|
if apt_packages:
|
||||||
lines.append("RUN apt-get update && apt-get install -y \\\\")
|
lines.append("RUN apt-get update && apt-get install -y \\")
|
||||||
for pkg in apt_packages[:-1]:
|
for pkg in apt_packages[:-1]:
|
||||||
lines.append(f" {pkg} \\\\")
|
lines.append(f" {pkg} \\")
|
||||||
lines.append(f" {apt_packages[-1]} \\\\")
|
lines.append(f" {apt_packages[-1]} \\")
|
||||||
lines.append(" && rm -rf /var/lib/apt/lists/*")
|
lines.append(" && rm -rf /var/lib/apt/lists/*")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
@@ -129,9 +136,9 @@ def compile_dockerfile(manifest: dict) -> str:
|
|||||||
if node:
|
if node:
|
||||||
version = node.get("version", "20")
|
version = node.get("version", "20")
|
||||||
lines.append(
|
lines.append(
|
||||||
f"RUN curl -fsSL https://deb.nodesource.com/setup_{version}.x | bash - && \\\\"
|
f"RUN curl -fsSL https://deb.nodesource.com/setup_{version}.x | bash - && \\"
|
||||||
)
|
)
|
||||||
lines.append(" apt-get install -y nodejs && \\\\")
|
lines.append(" apt-get install -y nodejs && \\")
|
||||||
lines.append(" rm -rf /var/lib/apt/lists/*")
|
lines.append(" rm -rf /var/lib/apt/lists/*")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
@@ -157,9 +164,25 @@ def compile_dockerfile(manifest: dict) -> str:
|
|||||||
gid = user["gid"]
|
gid = user["gid"]
|
||||||
create_home = "-m " if user.get("create_home", True) else ""
|
create_home = "-m " if user.get("create_home", True) else ""
|
||||||
shell = user.get("shell", "/bin/bash")
|
shell = user.get("shell", "/bin/bash")
|
||||||
lines.append(f"RUN groupadd -g {gid} {name} && \\\\")
|
lines.append(f"RUN groupadd -g {gid} {name} && \\")
|
||||||
lines.append(f" useradd -u {uid} -g {gid} {create_home}-s {shell} {name}")
|
lines.append(f" useradd -u {uid} -g {gid} {create_home}-s {shell} {name}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
# Set HOME and USER for runtime compatibility
|
||||||
|
home = f"/home/{name}"
|
||||||
|
lines.append(f"ENV HOME={home}")
|
||||||
|
lines.append(f"ENV USER={name}")
|
||||||
|
lines.append("")
|
||||||
|
# Ensure home directory exists and is writable by the user
|
||||||
|
lines.append(
|
||||||
|
f"RUN mkdir -p {home} && chown {name}:{name} {home} && chmod 755 {home}"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Configure passwordless sudo so startup scripts can fix permissions
|
||||||
|
lines.append(
|
||||||
|
f'RUN echo "{name} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/{name} && chmod 0440 /etc/sudoers.d/{name}'
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
# Build scripts
|
# Build scripts
|
||||||
build_scripts = manifest.get("scripts", {}).get("build", [])
|
build_scripts = manifest.get("scripts", {}).get("build", [])
|
||||||
@@ -174,6 +197,11 @@ def compile_dockerfile(manifest: dict) -> str:
|
|||||||
if build_scripts:
|
if build_scripts:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
|
# After build scripts, ensure everything in home is owned by the user
|
||||||
|
if user and build_scripts:
|
||||||
|
lines.append(f"RUN chown -R {name}:{name} {home}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
# Create mount target directories
|
# Create mount target directories
|
||||||
mounts = manifest.get("mounts", [])
|
mounts = manifest.get("mounts", [])
|
||||||
if mounts:
|
if mounts:
|
||||||
@@ -298,10 +326,24 @@ def compile_compose(manifest: dict, variables: dict[str, Any]) -> str:
|
|||||||
volumes.append(vol_str)
|
volumes.append(vol_str)
|
||||||
|
|
||||||
if volumes:
|
if volumes:
|
||||||
service["volumes"] = volumes
|
service["volumes"] = sort_volumes_by_specificity(volumes)
|
||||||
|
|
||||||
compose = {"services": {"app": service}}
|
compose = {"services": {"app": service}}
|
||||||
return yaml.dump(compose, default_flow_style=False)
|
result = yaml.dump(compose, default_flow_style=False)
|
||||||
|
|
||||||
|
# Debug: log mount resolution so we can diagnose missing mounts
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.debug(
|
||||||
|
"compile_compose: REPO_PATH=%s SSH_PATH=%s EXTRA_VOLUMES=%s mounts=%s volumes=%s",
|
||||||
|
variables.get("REPO_PATH", "<empty>"),
|
||||||
|
variables.get("SSH_PATH", "<empty>"),
|
||||||
|
variables.get("EXTRA_VOLUMES", []),
|
||||||
|
manifest.get("mounts", []),
|
||||||
|
volumes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
|
def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
|
||||||
@@ -333,6 +375,21 @@ def resolve_mount_source(mount: dict, variables: dict[str, Any]) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest_home_dir(manifest: dict) -> str:
|
||||||
|
"""Get the home directory for a container based on manifest user config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest: Fully resolved manifest JSON.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Home directory path (e.g., /home/user or /root).
|
||||||
|
"""
|
||||||
|
user = manifest.get("user")
|
||||||
|
if user and user.get("name"):
|
||||||
|
return f"/home/{user['name']}"
|
||||||
|
return "/root"
|
||||||
|
|
||||||
|
|
||||||
def compute_image_tag(tool_name: str, manifest: dict) -> str:
|
def compute_image_tag(tool_name: str, manifest: dict) -> str:
|
||||||
"""Compute a deterministic image tag from manifest content.
|
"""Compute a deterministic image tag from manifest content.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""Notification persistence service."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.engine import CursorResult
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.notification import Notification
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationService:
|
||||||
|
"""Singleton notification persistence service.
|
||||||
|
|
||||||
|
All methods filter by user_id to enforce strict ownership isolation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def create_notification(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
category: str,
|
||||||
|
severity: str,
|
||||||
|
title: str,
|
||||||
|
message: str | None = None,
|
||||||
|
source_type: str | None = None,
|
||||||
|
source_id: uuid.UUID | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> Notification:
|
||||||
|
"""Insert a new notification row.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
user_id: Owner of the notification.
|
||||||
|
category: Notification category (e.g., instance, system, health).
|
||||||
|
severity: Severity level (e.g., info, warning, error, success).
|
||||||
|
title: Short notification title.
|
||||||
|
message: Optional longer message body.
|
||||||
|
source_type: Optional source entity type.
|
||||||
|
source_id: Optional source entity UUID.
|
||||||
|
metadata: Optional JSON metadata dictionary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created Notification instance.
|
||||||
|
"""
|
||||||
|
notification = Notification(
|
||||||
|
user_id=user_id,
|
||||||
|
category=category,
|
||||||
|
severity=severity,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
source_type=source_type,
|
||||||
|
source_id=source_id,
|
||||||
|
notification_metadata=metadata or {},
|
||||||
|
)
|
||||||
|
session.add(notification)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(notification)
|
||||||
|
return notification
|
||||||
|
|
||||||
|
async def list_notifications(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
unread_only: bool = False,
|
||||||
|
mute_categories: list[str] | None = None,
|
||||||
|
) -> tuple[list[Notification], int]:
|
||||||
|
"""Return paginated notifications for a user.
|
||||||
|
|
||||||
|
Excludes dismissed notifications and applies optional filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
user_id: Owner of the notifications.
|
||||||
|
limit: Maximum number of items to return.
|
||||||
|
offset: Number of items to skip.
|
||||||
|
unread_only: If True, only return unread notifications.
|
||||||
|
mute_categories: Categories to exclude from results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of (items, total_count).
|
||||||
|
"""
|
||||||
|
where_clauses = [
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
Notification.dismissed_at.is_(None),
|
||||||
|
]
|
||||||
|
|
||||||
|
if unread_only:
|
||||||
|
where_clauses.append(Notification.read_at.is_(None))
|
||||||
|
|
||||||
|
if mute_categories:
|
||||||
|
where_clauses.append(Notification.category.not_in(mute_categories))
|
||||||
|
|
||||||
|
total_stmt = (
|
||||||
|
select(func.count()).select_from(Notification).where(*where_clauses)
|
||||||
|
)
|
||||||
|
total_result = await session.execute(total_stmt)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
items_stmt = (
|
||||||
|
select(Notification)
|
||||||
|
.where(*where_clauses)
|
||||||
|
.order_by(Notification.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
items_result = await session.execute(items_stmt)
|
||||||
|
items = list(items_result.scalars().all())
|
||||||
|
|
||||||
|
return items, total
|
||||||
|
|
||||||
|
async def get_unread_count(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> int:
|
||||||
|
"""Count unread, non-dismissed notifications for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
user_id: Owner of the notifications.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of unread notifications.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Notification)
|
||||||
|
.where(
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
Notification.read_at.is_(None),
|
||||||
|
Notification.dismissed_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
async def mark_read(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> Notification:
|
||||||
|
"""Mark a single notification as read.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
notification_id: UUID of the notification to mark.
|
||||||
|
user_id: Owner of the notification.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated Notification instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the notification does not exist or is not owned by the user.
|
||||||
|
"""
|
||||||
|
notification = await self._get_owned_notification(
|
||||||
|
session, notification_id, user_id
|
||||||
|
)
|
||||||
|
notification.read_at = datetime.now(timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(notification)
|
||||||
|
return notification
|
||||||
|
|
||||||
|
async def mark_all_read(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> int:
|
||||||
|
"""Mark all unread notifications as read for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
user_id: Owner of the notifications.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of rows updated.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
update(Notification)
|
||||||
|
.where(
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
Notification.read_at.is_(None),
|
||||||
|
Notification.dismissed_at.is_(None),
|
||||||
|
)
|
||||||
|
.values(read_at=datetime.now(timezone.utc))
|
||||||
|
)
|
||||||
|
result: CursorResult[Any] = await session.execute(stmt) # type: ignore[assignment]
|
||||||
|
await session.commit()
|
||||||
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
async def dismiss_all(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> int:
|
||||||
|
"""Soft-delete all non-dismissed notifications for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
user_id: Owner of the notifications.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of rows updated.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
update(Notification)
|
||||||
|
.where(
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
Notification.dismissed_at.is_(None),
|
||||||
|
)
|
||||||
|
.values(dismissed_at=datetime.now(timezone.utc))
|
||||||
|
)
|
||||||
|
result: CursorResult[Any] = await session.execute(stmt) # type: ignore[assignment]
|
||||||
|
await session.commit()
|
||||||
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
async def dismiss(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> None:
|
||||||
|
"""Soft-delete a notification by setting dismissed_at.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
notification_id: UUID of the notification to dismiss.
|
||||||
|
user_id: Owner of the notification.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the notification does not exist or is not owned by the user.
|
||||||
|
"""
|
||||||
|
notification = await self._get_owned_notification(
|
||||||
|
session, notification_id, user_id
|
||||||
|
)
|
||||||
|
notification.dismissed_at = datetime.now(timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def _get_owned_notification(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
notification_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
) -> Notification:
|
||||||
|
"""Fetch a notification and verify ownership.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session.
|
||||||
|
notification_id: UUID of the notification.
|
||||||
|
user_id: Expected owner.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The Notification instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the notification does not exist or is not owned.
|
||||||
|
"""
|
||||||
|
notification = await session.get(Notification, notification_id)
|
||||||
|
if notification is None or notification.user_id != user_id:
|
||||||
|
raise ValueError("Notification not found")
|
||||||
|
return notification
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton instance
|
||||||
|
notification_service = NotificationService()
|
||||||
@@ -40,6 +40,17 @@ def apply_mount_permissions(
|
|||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Skip read-only mounts — their permissions cannot be changed
|
||||||
|
# post-start because the bind mount is locked.
|
||||||
|
if mount.get("readonly", False):
|
||||||
|
logger.debug(
|
||||||
|
"Skipping permission fix for read-only mount %s (target=%s)",
|
||||||
|
name,
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
continue
|
||||||
|
|
||||||
# Skip if no permission policy defined
|
# Skip if no permission policy defined
|
||||||
if not owner and not mode and not file_mode:
|
if not owner and not mode and not file_mode:
|
||||||
results.append(result)
|
results.append(result)
|
||||||
@@ -104,6 +115,141 @@ def apply_mount_permissions(
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_and_log(
|
||||||
|
container_id: str,
|
||||||
|
command: list[str],
|
||||||
|
timeout: int,
|
||||||
|
description: str,
|
||||||
|
) -> str:
|
||||||
|
"""Run a docker exec command and log stdout/stderr for debugging."""
|
||||||
|
cmd = ["docker", "exec", "--user", "root", container_id] + command
|
||||||
|
logger.debug("[SSH-fix] %s: %s", description, " ".join(cmd))
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise PermissionFixError(
|
||||||
|
f"Command timed out after {timeout}s: {' '.join(command)}"
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise PermissionFixError(f"Docker command not found: {' '.join(command)}")
|
||||||
|
|
||||||
|
stdout = result.stdout.strip()
|
||||||
|
stderr = result.stderr.strip()
|
||||||
|
if stdout:
|
||||||
|
logger.debug("[SSH-fix] %s stdout: %s", description, stdout)
|
||||||
|
if stderr:
|
||||||
|
logger.debug("[SSH-fix] %s stderr: %s", description, stderr)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise PermissionFixError(
|
||||||
|
f"Command failed (rc={result.returncode}): {stderr or '(no stderr)'}"
|
||||||
|
)
|
||||||
|
return stdout
|
||||||
|
|
||||||
|
|
||||||
|
def apply_ssh_permissions(
|
||||||
|
container_id: str,
|
||||||
|
ssh_target: str,
|
||||||
|
container_user: str,
|
||||||
|
timeout: int = 10,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fix SSH directory ownership and permissions in a running container.
|
||||||
|
|
||||||
|
Runs chown and chmod on the ~/.ssh directory so the container user
|
||||||
|
can use the keys (SSH requires the private key to be owned by the
|
||||||
|
user with mode 600).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name.
|
||||||
|
ssh_target: Absolute path to the .ssh directory inside the container.
|
||||||
|
container_user: The container user that should own the keys.
|
||||||
|
timeout: Max seconds per docker exec command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result dict with keys: success, error.
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = {"success": True, "error": None}
|
||||||
|
try:
|
||||||
|
# 1. Ensure directory is owned by the container user
|
||||||
|
_exec_and_log(
|
||||||
|
container_id,
|
||||||
|
["chown", "-R", f"{container_user}:{container_user}", ssh_target],
|
||||||
|
timeout,
|
||||||
|
"chown",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Set directory permissions
|
||||||
|
_exec_and_log(
|
||||||
|
container_id,
|
||||||
|
["chmod", "700", ssh_target],
|
||||||
|
timeout,
|
||||||
|
"chmod-dir",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Set private key permissions (id_ed25519, id_rsa, etc.)
|
||||||
|
_exec_and_log(
|
||||||
|
container_id,
|
||||||
|
[
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
f"find {ssh_target} -name 'id_*' -type f -exec chmod 600 {{}} +",
|
||||||
|
],
|
||||||
|
timeout,
|
||||||
|
"chmod-keys",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Verify final state
|
||||||
|
ls_output = _exec_and_log(
|
||||||
|
container_id,
|
||||||
|
["ls", "-la", ssh_target],
|
||||||
|
timeout,
|
||||||
|
"verify-ls",
|
||||||
|
)
|
||||||
|
stat_output = _exec_and_log(
|
||||||
|
container_id,
|
||||||
|
["stat", "-c", "%U:%G %a %n", ssh_target],
|
||||||
|
timeout,
|
||||||
|
"verify-stat-dir",
|
||||||
|
)
|
||||||
|
key_stat = _exec_and_log(
|
||||||
|
container_id,
|
||||||
|
[
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
f"stat -c '%U:%G %a %n' {ssh_target}/id_* 2>/dev/null || echo 'no id_* files found'",
|
||||||
|
],
|
||||||
|
timeout,
|
||||||
|
"verify-stat-keys",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"SSH permissions fixed for container %s (user=%s, target=%s). "
|
||||||
|
"ls:\n%s\nstat-dir: %s\nstat-keys: %s",
|
||||||
|
container_id,
|
||||||
|
container_user,
|
||||||
|
ssh_target,
|
||||||
|
ls_output,
|
||||||
|
stat_output,
|
||||||
|
key_stat,
|
||||||
|
)
|
||||||
|
except PermissionFixError as exc:
|
||||||
|
result["success"] = False
|
||||||
|
result["error"] = str(exc)
|
||||||
|
logger.warning(
|
||||||
|
"SSH permission fix failed for container %s (target=%s): %s",
|
||||||
|
container_id,
|
||||||
|
ssh_target,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class PermissionFixError(Exception):
|
class PermissionFixError(Exception):
|
||||||
"""Raised when a permission fix command fails."""
|
"""Raised when a permission fix command fails."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
"""SSH key service utilities for preparing keys for container use."""
|
"""SSH key service utilities for preparing keys for container use."""
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _get_fernet() -> Fernet:
|
def _get_fernet() -> Fernet:
|
||||||
"""Generate a valid Fernet key from the session secret."""
|
"""Generate a valid Fernet key from the session secret."""
|
||||||
@@ -19,17 +23,48 @@ def _get_fernet() -> Fernet:
|
|||||||
return Fernet(key)
|
return Fernet(key)
|
||||||
|
|
||||||
|
|
||||||
def prepare_ssh_key_files(instance_dir: str, ssh_key) -> str:
|
def _sanitize_filename(name: str) -> str:
|
||||||
|
"""Sanitize a string for use as a filename.
|
||||||
|
|
||||||
|
Replaces non-alphanumeric characters with underscores and strips
|
||||||
|
leading/trailing underscores.
|
||||||
|
"""
|
||||||
|
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", name)
|
||||||
|
sanitized = sanitized.strip("_")
|
||||||
|
# Ensure it's not empty
|
||||||
|
if not sanitized:
|
||||||
|
sanitized = "key"
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_ssh_key_files(
|
||||||
|
instance_dir: str,
|
||||||
|
ssh_key,
|
||||||
|
subdir: str = ".ssh",
|
||||||
|
uid: int | None = None,
|
||||||
|
gid: int | None = None,
|
||||||
|
key_filename: str = "id_ed25519",
|
||||||
|
write_config: bool = True,
|
||||||
|
) -> str:
|
||||||
"""Decrypt and write SSH key files to instance directory for container mounting.
|
"""Decrypt and write SSH key files to instance directory for container mounting.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_dir: Path to instance directory
|
instance_dir: Path to instance directory
|
||||||
ssh_key: SSHKey model instance with encrypted private key
|
ssh_key: SSHKey model instance with encrypted private key
|
||||||
|
subdir: Subdirectory within instance_dir to write to (default: ".ssh")
|
||||||
|
uid: Optional UID to own the files (for bind-mount into non-root container)
|
||||||
|
gid: Optional GID to own the files
|
||||||
|
key_filename: Base filename for the key pair (default: "id_ed25519").
|
||||||
|
The private key will be named "{key_filename}" and the public key
|
||||||
|
"{key_filename}.pub".
|
||||||
|
write_config: Whether to write an SSH config file (default: True).
|
||||||
|
Set to False when combining multiple keys into one directory,
|
||||||
|
then call write_ssh_config() separately.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to the .ssh directory
|
Path to the .ssh directory
|
||||||
"""
|
"""
|
||||||
ssh_dir = Path(instance_dir) / ".ssh"
|
ssh_dir = Path(instance_dir) / subdir
|
||||||
ssh_dir.mkdir(parents=True, exist_ok=True)
|
ssh_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Decrypt private key
|
# Decrypt private key
|
||||||
@@ -37,29 +72,103 @@ def prepare_ssh_key_files(instance_dir: str, ssh_key) -> str:
|
|||||||
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
private_key = fernet.decrypt(ssh_key.private_key_encrypted.encode()).decode()
|
||||||
|
|
||||||
# Write private key with restricted permissions
|
# Write private key with restricted permissions
|
||||||
private_key_path = ssh_dir / "id_ed25519"
|
private_key_path = ssh_dir / key_filename
|
||||||
private_key_path.write_text(private_key)
|
private_key_path.write_text(private_key)
|
||||||
os.chmod(private_key_path, 0o600)
|
os.chmod(private_key_path, 0o600)
|
||||||
|
|
||||||
# Write public key
|
# Write public key
|
||||||
public_key_path = ssh_dir / "id_ed25519.pub"
|
public_key_path = ssh_dir / f"{key_filename}.pub"
|
||||||
public_key_path.write_text(ssh_key.public_key)
|
public_key_path.write_text(ssh_key.public_key)
|
||||||
os.chmod(public_key_path, 0o644)
|
os.chmod(public_key_path, 0o644)
|
||||||
|
|
||||||
# Write SSH config
|
# Write SSH config (only if requested)
|
||||||
|
if write_config:
|
||||||
config_path = ssh_dir / "config"
|
config_path = ssh_dir / "config"
|
||||||
config_content = """Host *
|
config_content = f"""Host *
|
||||||
StrictHostKeyChecking no
|
StrictHostKeyChecking no
|
||||||
UserKnownHostsFile /dev/null
|
UserKnownHostsFile /dev/null
|
||||||
IdentityFile ~/.ssh/id_ed25519
|
IdentityFile ~/.ssh/{key_filename}
|
||||||
IdentitiesOnly yes
|
IdentitiesOnly yes
|
||||||
"""
|
"""
|
||||||
config_path.write_text(config_content)
|
config_path.write_text(config_content)
|
||||||
os.chmod(config_path, 0o644)
|
os.chmod(config_path, 0o644)
|
||||||
|
|
||||||
|
# Set ownership to target container user if requested
|
||||||
|
if uid is not None or gid is not None:
|
||||||
|
effective_uid = uid if uid is not None else -1
|
||||||
|
effective_gid = gid if gid is not None else -1
|
||||||
|
try:
|
||||||
|
os.chown(ssh_dir, effective_uid, effective_gid)
|
||||||
|
os.chown(private_key_path, effective_uid, effective_gid)
|
||||||
|
os.chown(public_key_path, effective_uid, effective_gid)
|
||||||
|
os.chown(config_path, effective_uid, effective_gid)
|
||||||
|
logger.debug(
|
||||||
|
"Set SSH key ownership to uid=%s gid=%s for %s",
|
||||||
|
effective_uid,
|
||||||
|
effective_gid,
|
||||||
|
ssh_dir,
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Cannot chown SSH keys to uid=%s gid=%s (running as uid=%s): %s",
|
||||||
|
effective_uid,
|
||||||
|
effective_gid,
|
||||||
|
os.getuid(),
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Still chown the key files even if we didn't write config
|
||||||
|
if uid is not None or gid is not None:
|
||||||
|
effective_uid = uid if uid is not None else -1
|
||||||
|
effective_gid = gid if gid is not None else -1
|
||||||
|
try:
|
||||||
|
os.chown(private_key_path, effective_uid, effective_gid)
|
||||||
|
os.chown(public_key_path, effective_uid, effective_gid)
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
|
||||||
return str(ssh_dir)
|
return str(ssh_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def write_ssh_config(
|
||||||
|
ssh_dir: str,
|
||||||
|
key_filenames: list[str],
|
||||||
|
uid: int | None = None,
|
||||||
|
gid: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Write an SSH config file that includes multiple IdentityFile entries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ssh_dir: Path to the .ssh directory
|
||||||
|
key_filenames: List of key filenames (without .pub extension)
|
||||||
|
uid: Optional UID to own the config file
|
||||||
|
gid: Optional GID to own the config file
|
||||||
|
"""
|
||||||
|
ssh_dir_path = Path(ssh_dir)
|
||||||
|
ssh_dir_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
config_path = ssh_dir_path / "config"
|
||||||
|
lines = ["Host *"]
|
||||||
|
lines.append(" StrictHostKeyChecking no")
|
||||||
|
lines.append(" UserKnownHostsFile /dev/null")
|
||||||
|
lines.append(" IdentitiesOnly yes")
|
||||||
|
for filename in key_filenames:
|
||||||
|
lines.append(f" IdentityFile ~/.ssh/{filename}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
config_content = "\n".join(lines)
|
||||||
|
config_path.write_text(config_content)
|
||||||
|
os.chmod(config_path, 0o644)
|
||||||
|
|
||||||
|
if uid is not None or gid is not None:
|
||||||
|
effective_uid = uid if uid is not None else -1
|
||||||
|
effective_gid = gid if gid is not None else -1
|
||||||
|
try:
|
||||||
|
os.chown(config_path, effective_uid, effective_gid)
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def cleanup_ssh_key_files(instance_dir: str) -> None:
|
def cleanup_ssh_key_files(instance_dir: str) -> None:
|
||||||
"""Remove temporary SSH key files from instance directory.
|
"""Remove temporary SSH key files from instance directory.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
from src.database import SessionLocal
|
from src.database import SessionLocal
|
||||||
from src.models.terminal_session import TerminalSessionModel
|
from src.models.terminal_session import TerminalSessionModel
|
||||||
@@ -83,10 +84,16 @@ class TerminalManager:
|
|||||||
instance_id: uuid.UUID,
|
instance_id: uuid.UUID,
|
||||||
name: str,
|
name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Insert a TerminalSessionModel row into the database."""
|
"""Insert a TerminalSessionModel row into the database.
|
||||||
|
|
||||||
|
Uses ON CONFLICT DO NOTHING to handle races when a session is
|
||||||
|
restored from DB and then re-inserted.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
async with SessionLocal() as db_session:
|
async with SessionLocal() as db_session:
|
||||||
db_row = TerminalSessionModel(
|
stmt = (
|
||||||
|
pg_insert(TerminalSessionModel)
|
||||||
|
.values(
|
||||||
id=uuid.UUID(session_id),
|
id=uuid.UUID(session_id),
|
||||||
instance_id=instance_id,
|
instance_id=instance_id,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -94,7 +101,9 @@ class TerminalManager:
|
|||||||
created_at=datetime.now(timezone.utc),
|
created_at=datetime.now(timezone.utc),
|
||||||
last_activity_at=datetime.now(timezone.utc),
|
last_activity_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
db_session.add(db_row)
|
.on_conflict_do_nothing(index_elements=["id"])
|
||||||
|
)
|
||||||
|
await db_session.execute(stmt)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Inserted terminal session row %s for instance %s",
|
"Inserted terminal session row %s for instance %s",
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""Terminal session management for tool instances."""
|
"""High-performance terminal session with asyncio-native I/O.
|
||||||
|
|
||||||
|
Replaces blocking select.select() with event-driven asyncio.add_reader()
|
||||||
|
for sub-frame latency. Includes output batching and flow control.
|
||||||
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import pty
|
import pty
|
||||||
import select
|
|
||||||
import signal
|
import signal
|
||||||
import struct
|
import struct
|
||||||
import fcntl
|
import fcntl
|
||||||
@@ -17,18 +20,31 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class TerminalSession:
|
class TerminalSession:
|
||||||
"""Manages a single terminal session connected to a docker container.
|
"""Manages a single terminal session with event-driven PTY I/O.
|
||||||
|
|
||||||
Supports persistent sessions that survive WebSocket disconnections.
|
Uses asyncio.add_reader() instead of polling for near-zero read latency.
|
||||||
Multiple WebSocket connections can attach/detach from the same session.
|
Output is batched (2ms window) and sent as binary WebSocket frames.
|
||||||
|
Flow control prevents memory bloat on fast output.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Circular buffer size (10KB)
|
# Circular buffer for replay (10KB)
|
||||||
BUFFER_SIZE = 10 * 1024
|
BUFFER_SIZE = 10 * 1024
|
||||||
|
|
||||||
# Idle timeout in seconds (30 minutes)
|
# Idle timeout in seconds (30 minutes)
|
||||||
IDLE_TIMEOUT = 30 * 60
|
IDLE_TIMEOUT = 30 * 60
|
||||||
|
|
||||||
|
# Output batching window in seconds
|
||||||
|
BATCH_WINDOW_S = 0.002 # 2ms
|
||||||
|
|
||||||
|
# Flow control: pause PTY reads when unacknowledged bytes exceed this
|
||||||
|
FLOW_CONTROL_PAUSE = 64 * 1024
|
||||||
|
|
||||||
|
# Flow control: resume PTY reads when unacknowledged bytes drop below this
|
||||||
|
FLOW_CONTROL_RESUME = 32 * 1024
|
||||||
|
|
||||||
|
# Max WebSocket frame size
|
||||||
|
MAX_FRAME_SIZE = 64 * 1024
|
||||||
|
|
||||||
# Session number counter per instance_id for auto-naming
|
# Session number counter per instance_id for auto-naming
|
||||||
_instance_counters: dict[str, int] = {}
|
_instance_counters: dict[str, int] = {}
|
||||||
|
|
||||||
@@ -47,7 +63,6 @@ class TerminalSession:
|
|||||||
self.process: asyncio.subprocess.Process | None = None
|
self.process: asyncio.subprocess.Process | None = None
|
||||||
self._closed = False
|
self._closed = False
|
||||||
self._master_fd: int | None = None
|
self._master_fd: int | None = None
|
||||||
self._slave_fd: int | None = None
|
|
||||||
|
|
||||||
# Circular buffer for output replay
|
# Circular buffer for output replay
|
||||||
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
|
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
|
||||||
@@ -67,6 +82,20 @@ class TerminalSession:
|
|||||||
self.name = name or self._generate_name(str(instance_id))
|
self.name = name or self._generate_name(str(instance_id))
|
||||||
self.status: str = "active"
|
self.status: str = "active"
|
||||||
|
|
||||||
|
# Output batching
|
||||||
|
self._batch_buffer = bytearray()
|
||||||
|
self._batch_timer: asyncio.TimerHandle | None = None
|
||||||
|
self._batch_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# Flow control
|
||||||
|
self._unacknowledged_bytes = 0
|
||||||
|
self._paused = False
|
||||||
|
self._read_handler_set = False
|
||||||
|
self._flow_control_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# Ack timeout fallback
|
||||||
|
self._ack_timeout_handle: asyncio.TimerHandle | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _generate_name(cls, instance_id: str) -> str:
|
def _generate_name(cls, instance_id: str) -> str:
|
||||||
"""Generate an auto-incremented session name for the instance."""
|
"""Generate an auto-incremented session name for the instance."""
|
||||||
@@ -77,91 +106,225 @@ class TerminalSession:
|
|||||||
async def start(self, startup_command: str | None = None) -> None:
|
async def start(self, startup_command: str | None = None) -> None:
|
||||||
"""Start the docker exec process with a shell using a PTY."""
|
"""Start the docker exec process with a shell using a PTY."""
|
||||||
# Create a pseudo-terminal on the host
|
# Create a pseudo-terminal on the host
|
||||||
self._master_fd, self._slave_fd = pty.openpty()
|
self._master_fd, slave_fd = pty.openpty()
|
||||||
|
|
||||||
# Set the terminal size initially
|
# Set the terminal size initially
|
||||||
self._set_terminal_size(self._cols, self._rows)
|
self._set_terminal_size(self._cols, self._rows)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}"
|
"Starting terminal session %s for container %s with initial size %sx%s",
|
||||||
|
self.session_id,
|
||||||
|
self.container_id,
|
||||||
|
self._cols,
|
||||||
|
self._rows,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build the shell command
|
# Build the shell command
|
||||||
if startup_command:
|
cmd = startup_command or self.startup_command
|
||||||
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
|
if cmd:
|
||||||
|
shell_cmd = f'bash -c "{cmd}" || true; exec bash -il'
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Using startup command for session {self.session_id}: {startup_command}"
|
"Using startup command for session %s: %s",
|
||||||
|
self.session_id,
|
||||||
|
cmd,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
shell_cmd = "bash -il"
|
shell_cmd = "bash -il"
|
||||||
|
|
||||||
# Start docker exec with the slave fd as stdin/stdout/stderr
|
# Start docker exec with the slave fd as stdin/stdout/stderr
|
||||||
# Using -it because the slave fd IS a TTY
|
|
||||||
self.process = await asyncio.create_subprocess_exec(
|
self.process = await asyncio.create_subprocess_exec(
|
||||||
"docker",
|
"docker",
|
||||||
"exec",
|
"exec",
|
||||||
"-it",
|
"-it",
|
||||||
"-e",
|
"-e",
|
||||||
"TERM=xterm",
|
"TERM=xterm-256color",
|
||||||
self.container_id,
|
self.container_id,
|
||||||
"bash",
|
"bash",
|
||||||
"-c",
|
"-c",
|
||||||
shell_cmd,
|
shell_cmd,
|
||||||
stdin=self._slave_fd,
|
stdin=slave_fd,
|
||||||
stdout=self._slave_fd,
|
stdout=slave_fd,
|
||||||
stderr=self._slave_fd,
|
stderr=slave_fd,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Close slave fd in parent process
|
# Close slave fd in parent process
|
||||||
os.close(self._slave_fd)
|
os.close(slave_fd)
|
||||||
self._slave_fd = None
|
|
||||||
|
|
||||||
self.last_activity = time.time()
|
self.last_activity = time.time()
|
||||||
|
|
||||||
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
# Start event-driven reading
|
||||||
"""Set the terminal size using TIOCSWINSZ."""
|
self._start_reading()
|
||||||
if self._master_fd is None:
|
|
||||||
logger.warning("Cannot resize: master_fd is None (session not started)")
|
def _start_reading(self) -> None:
|
||||||
return
|
"""Register PTY master fd with asyncio event loop for event-driven reads."""
|
||||||
# TIOCSWINSZ = 0x5414 on Linux
|
if self._read_handler_set or self._master_fd is None or self._closed:
|
||||||
TIOCSWINSZ = 0x5414
|
return
|
||||||
size = struct.pack("HHHH", rows, cols, 0, 0)
|
try:
|
||||||
try:
|
loop = asyncio.get_event_loop()
|
||||||
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
loop.add_reader(self._master_fd, self._on_fd_readable)
|
||||||
logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
|
self._read_handler_set = True
|
||||||
except (OSError, IOError) as e:
|
logger.debug("Started event-driven reading for session %s", self.session_id)
|
||||||
logger.error(f"Failed to resize PTY: {e}")
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to start reading for session %s: %s", self.session_id, exc
|
||||||
|
)
|
||||||
|
|
||||||
|
def _stop_reading(self) -> None:
|
||||||
|
"""Unregister PTY master fd from asyncio event loop."""
|
||||||
|
if not self._read_handler_set or self._master_fd is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.remove_reader(self._master_fd)
|
||||||
|
self._read_handler_set = False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_fd_readable(self) -> None:
|
||||||
|
"""Callback when PTY master fd has data available (called by event loop)."""
|
||||||
|
if self._master_fd is None or self._closed:
|
||||||
|
return
|
||||||
|
|
||||||
async def read_output(self) -> bytes:
|
|
||||||
"""Read output from the PTY master and store in buffer."""
|
|
||||||
if self._master_fd is None or self._closed:
|
|
||||||
return b""
|
|
||||||
try:
|
try:
|
||||||
# Use select to check if data is available
|
|
||||||
readable, _, _ = select.select([self._master_fd], [], [], 0.1)
|
|
||||||
if readable:
|
|
||||||
data = os.read(self._master_fd, 4096)
|
data = os.read(self._master_fd, 4096)
|
||||||
if data:
|
except (OSError, IOError) as exc:
|
||||||
|
logger.debug("PTY read error for session %s: %s", self.session_id, exc)
|
||||||
|
self._handle_eof()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
# EOF: docker exec process exited
|
||||||
|
logger.debug("PTY EOF for session %s", self.session_id)
|
||||||
|
self._handle_eof()
|
||||||
|
return
|
||||||
|
|
||||||
self._add_to_buffer(data)
|
self._add_to_buffer(data)
|
||||||
self.last_activity = time.time()
|
self.last_activity = time.time()
|
||||||
return data
|
|
||||||
return b""
|
# Queue for batching + flow control
|
||||||
except (OSError, IOError, ValueError):
|
self._queue_output(data)
|
||||||
return b""
|
|
||||||
|
|
||||||
def _add_to_buffer(self, data: bytes) -> None:
|
def _add_to_buffer(self, data: bytes) -> None:
|
||||||
"""Add data to circular buffer, maintaining size limit."""
|
"""Add data to circular buffer, maintaining size limit."""
|
||||||
self._output_buffer.append(data)
|
self._output_buffer.append(data)
|
||||||
self._buffer_size += len(data)
|
self._buffer_size += len(data)
|
||||||
|
|
||||||
# Trim if exceeds max size
|
|
||||||
while self._buffer_size > self.BUFFER_SIZE and self._output_buffer:
|
while self._buffer_size > self.BUFFER_SIZE and self._output_buffer:
|
||||||
removed = self._output_buffer.popleft()
|
removed = self._output_buffer.popleft()
|
||||||
self._buffer_size -= len(removed)
|
self._buffer_size -= len(removed)
|
||||||
|
|
||||||
|
def _queue_output(self, data: bytes) -> None:
|
||||||
|
"""Add output to batch buffer and schedule flush."""
|
||||||
|
self._batch_buffer.extend(data)
|
||||||
|
self._unacknowledged_bytes += len(data)
|
||||||
|
|
||||||
|
# Check flow control
|
||||||
|
if self._unacknowledged_bytes > self.FLOW_CONTROL_PAUSE and not self._paused:
|
||||||
|
self._pause_output()
|
||||||
|
|
||||||
|
# Schedule batch flush if not already scheduled
|
||||||
|
if self._batch_timer is None:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
self._batch_timer = loop.call_later(
|
||||||
|
self.BATCH_WINDOW_S,
|
||||||
|
self._flush_batch_sync,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _flush_batch_sync(self) -> None:
|
||||||
|
"""Synchronous entry point for batch flush (called from event loop)."""
|
||||||
|
self._batch_timer = None
|
||||||
|
if not self._batch_buffer or not self._websockets:
|
||||||
|
self._batch_buffer.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = bytes(self._batch_buffer)
|
||||||
|
self._batch_buffer.clear()
|
||||||
|
|
||||||
|
# Send to all websockets (asyncio.create_task for async send)
|
||||||
|
dead_sockets = set()
|
||||||
|
for ws in list(self._websockets):
|
||||||
|
try:
|
||||||
|
asyncio.create_task(self._send_bytes(ws, payload))
|
||||||
|
except Exception:
|
||||||
|
dead_sockets.add(ws)
|
||||||
|
|
||||||
|
if dead_sockets:
|
||||||
|
self._websockets -= dead_sockets
|
||||||
|
|
||||||
|
async def _send_bytes(self, ws: Any, payload: bytes) -> None:
|
||||||
|
"""Send bytes to a single websocket, catching errors."""
|
||||||
|
try:
|
||||||
|
await ws.send_bytes(payload)
|
||||||
|
except Exception:
|
||||||
|
self._websockets.discard(ws)
|
||||||
|
|
||||||
|
def acknowledge_data(self, char_count: int) -> None:
|
||||||
|
"""Client acknowledges processing char_count bytes.
|
||||||
|
|
||||||
|
Called from the WebSocket handler when the client sends an 'ack' message.
|
||||||
|
"""
|
||||||
|
self._unacknowledged_bytes = max(0, self._unacknowledged_bytes - char_count)
|
||||||
|
|
||||||
|
if self._paused and self._unacknowledged_bytes < self.FLOW_CONTROL_RESUME:
|
||||||
|
self._resume_output()
|
||||||
|
|
||||||
|
# Reset ack timeout
|
||||||
|
if self._ack_timeout_handle:
|
||||||
|
self._ack_timeout_handle.cancel()
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
self._ack_timeout_handle = loop.call_later(5.0, self._ack_timeout_fallback)
|
||||||
|
|
||||||
|
def _ack_timeout_fallback(self) -> None:
|
||||||
|
"""If no ack received for 5s, assume client is dead and resume."""
|
||||||
|
logger.warning(
|
||||||
|
"Flow control ack timeout for session %s, resuming output",
|
||||||
|
self.session_id,
|
||||||
|
)
|
||||||
|
self._unacknowledged_bytes = 0
|
||||||
|
if self._paused:
|
||||||
|
self._resume_output()
|
||||||
|
|
||||||
|
def _pause_output(self) -> None:
|
||||||
|
"""Pause reading from PTY due to flow control."""
|
||||||
|
self._paused = True
|
||||||
|
self._stop_reading()
|
||||||
|
logger.debug(
|
||||||
|
"Paused output for session %s (%d unacked)",
|
||||||
|
self.session_id,
|
||||||
|
self._unacknowledged_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resume_output(self) -> None:
|
||||||
|
"""Resume reading from PTY."""
|
||||||
|
self._paused = False
|
||||||
|
self._start_reading()
|
||||||
|
logger.debug("Resumed output for session %s", self.session_id)
|
||||||
|
|
||||||
def get_buffer(self) -> bytes:
|
def get_buffer(self) -> bytes:
|
||||||
"""Get buffered output for replay."""
|
"""Get buffered output for replay."""
|
||||||
return b"".join(self._output_buffer)
|
return b"".join(self._output_buffer)
|
||||||
|
|
||||||
|
def _handle_eof(self) -> None:
|
||||||
|
"""Handle PTY EOF: process died, close websockets to force reconnect."""
|
||||||
|
self._stop_reading()
|
||||||
|
# Mark process as done so is_alive() returns False
|
||||||
|
if self.process is not None and self.process.returncode is None:
|
||||||
|
# Force returncode to a non-None value since the process is dead
|
||||||
|
# but asyncio.subprocess may not have set it yet
|
||||||
|
try:
|
||||||
|
self.process._transport.close() # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Close all websockets to force frontend reconnection
|
||||||
|
dead_sockets = set(self._websockets)
|
||||||
|
self._websockets.clear()
|
||||||
|
for ws in dead_sockets:
|
||||||
|
try:
|
||||||
|
asyncio.create_task(
|
||||||
|
ws.close(code=4001, reason="Session process exited")
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info("Session %s EOF handled, websockets closed", self.session_id)
|
||||||
|
|
||||||
async def write_input(self, data: bytes) -> None:
|
async def write_input(self, data: bytes) -> None:
|
||||||
"""Write input to the PTY master."""
|
"""Write input to the PTY master."""
|
||||||
if self._master_fd is None or self._closed:
|
if self._master_fd is None or self._closed:
|
||||||
@@ -169,8 +332,22 @@ class TerminalSession:
|
|||||||
try:
|
try:
|
||||||
os.write(self._master_fd, data)
|
os.write(self._master_fd, data)
|
||||||
self.last_activity = time.time()
|
self.last_activity = time.time()
|
||||||
except (OSError, IOError):
|
except (OSError, IOError) as exc:
|
||||||
pass
|
logger.debug("PTY write error for session %s: %s", self.session_id, exc)
|
||||||
|
self._handle_eof()
|
||||||
|
|
||||||
|
def _set_terminal_size(self, cols: int, rows: int) -> None:
|
||||||
|
"""Set the terminal size using TIOCSWINSZ."""
|
||||||
|
if self._master_fd is None:
|
||||||
|
logger.warning("Cannot resize: master_fd is None (session not started)")
|
||||||
|
return
|
||||||
|
TIOCSWINSZ = 0x5414
|
||||||
|
size = struct.pack("HHHH", rows, cols, 0, 0)
|
||||||
|
try:
|
||||||
|
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
|
||||||
|
logger.debug("Resized PTY to %sx%s (fd=%s)", cols, rows, self._master_fd)
|
||||||
|
except (OSError, IOError) as e:
|
||||||
|
logger.error("Failed to resize PTY: %s", e)
|
||||||
|
|
||||||
async def resize(self, cols: int, rows: int) -> None:
|
async def resize(self, cols: int, rows: int) -> None:
|
||||||
"""Resize the terminal."""
|
"""Resize the terminal."""
|
||||||
@@ -178,32 +355,24 @@ class TerminalSession:
|
|||||||
logger.warning("Cannot resize: session is closed")
|
logger.warning("Cannot resize: session is closed")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Only resize if dimensions actually changed
|
|
||||||
if cols == self._cols and rows == self._rows:
|
if cols == self._cols and rows == self._rows:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._cols = cols
|
self._cols = cols
|
||||||
self._rows = rows
|
self._rows = rows
|
||||||
logger.debug(f"resize() called for session {self.session_id}: {cols}x{rows}")
|
logger.debug(
|
||||||
|
"resize() called for session %s: %sx%s", self.session_id, cols, rows
|
||||||
|
)
|
||||||
self._set_terminal_size(cols, rows)
|
self._set_terminal_size(cols, rows)
|
||||||
|
|
||||||
# Docker exec -it creates its own PTY inside the container,
|
# Send SIGWINCH to docker exec process
|
||||||
# so host PTY resize doesn't propagate to the container shell.
|
|
||||||
# Send SIGWINCH to the docker exec process on the host.
|
|
||||||
# Docker exec forwards signals to the container process, which should
|
|
||||||
# cause the container's shell to re-read its terminal size.
|
|
||||||
if self.process and self.process.pid:
|
if self.process and self.process.pid:
|
||||||
try:
|
try:
|
||||||
os.kill(self.process.pid, signal.SIGWINCH)
|
os.kill(self.process.pid, signal.SIGWINCH)
|
||||||
logger.debug(
|
|
||||||
f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}"
|
|
||||||
)
|
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
logger.warning(
|
logger.warning("docker exec process %s not found", self.process.pid)
|
||||||
f"docker exec process {self.process.pid} not found for session {self.session_id}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to send SIGWINCH: {e}")
|
logger.warning("Failed to send SIGWINCH: %s", e)
|
||||||
|
|
||||||
async def reset(self) -> None:
|
async def reset(self) -> None:
|
||||||
"""Reset the session by killing the process and clearing state."""
|
"""Reset the session by killing the process and clearing state."""
|
||||||
@@ -213,9 +382,13 @@ class TerminalSession:
|
|||||||
self._output_buffer.clear()
|
self._output_buffer.clear()
|
||||||
self._buffer_size = 0
|
self._buffer_size = 0
|
||||||
self._websockets.clear()
|
self._websockets.clear()
|
||||||
|
self._batch_buffer.clear()
|
||||||
|
self._batch_timer = None
|
||||||
|
self._unacknowledged_bytes = 0
|
||||||
|
self._paused = False
|
||||||
|
self._read_handler_set = False
|
||||||
self.process = None
|
self.process = None
|
||||||
self._master_fd = None
|
self._master_fd = None
|
||||||
self._slave_fd = None
|
|
||||||
self.status = "active"
|
self.status = "active"
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
@@ -225,11 +398,21 @@ class TerminalSession:
|
|||||||
self._closed = True
|
self._closed = True
|
||||||
self.status = "closed"
|
self.status = "closed"
|
||||||
|
|
||||||
|
self._stop_reading()
|
||||||
|
|
||||||
|
if self._batch_timer:
|
||||||
|
self._batch_timer.cancel()
|
||||||
|
self._batch_timer = None
|
||||||
|
|
||||||
|
if self._ack_timeout_handle:
|
||||||
|
self._ack_timeout_handle.cancel()
|
||||||
|
self._ack_timeout_handle = None
|
||||||
|
|
||||||
if self._master_fd is not None:
|
if self._master_fd is not None:
|
||||||
try:
|
try:
|
||||||
os.close(self._master_fd)
|
os.close(self._master_fd)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # noqa: S110
|
pass
|
||||||
self._master_fd = None
|
self._master_fd = None
|
||||||
|
|
||||||
if self.process is not None:
|
if self.process is not None:
|
||||||
@@ -265,14 +448,20 @@ class TerminalSession:
|
|||||||
return len(self._websockets) > 0
|
return len(self._websockets) > 0
|
||||||
|
|
||||||
async def send_to_all(self, data: bytes) -> None:
|
async def send_to_all(self, data: bytes) -> None:
|
||||||
"""Send data to all attached WebSockets."""
|
"""Send data to all attached WebSockets (used for control messages)."""
|
||||||
dead_sockets = set()
|
dead_sockets = set()
|
||||||
for ws in self._websockets:
|
for ws in self._websockets:
|
||||||
try:
|
try:
|
||||||
await ws.send_bytes(data)
|
await ws.send_bytes(data)
|
||||||
except Exception:
|
except Exception:
|
||||||
dead_sockets.add(ws)
|
dead_sockets.add(ws)
|
||||||
|
|
||||||
# Clean up dead sockets
|
|
||||||
for ws in dead_sockets:
|
for ws in dead_sockets:
|
||||||
self._websockets.discard(ws)
|
self._websockets.discard(ws)
|
||||||
|
|
||||||
|
async def read_output(self) -> bytes:
|
||||||
|
"""Legacy method: read output synchronously.
|
||||||
|
|
||||||
|
With event-driven I/O, output is automatically sent to websockets.
|
||||||
|
This method returns any buffered data for callers that poll.
|
||||||
|
"""
|
||||||
|
return b""
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""Clean tunnel service using cloudflared containers on the backend network.
|
||||||
|
|
||||||
|
Design:
|
||||||
|
- Each tunnel runs as a Docker container on the same 'backend' network as the API.
|
||||||
|
- cloudflared connects to the tool container by its Docker Compose service name
|
||||||
|
(e.g. http://code-server-headquarter-34837cd3:8443).
|
||||||
|
- This avoids host port conflicts and DNS resolution issues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.services.docker import get_backend_network_name
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TUNNEL_IMAGE = "cloudflare/cloudflared:latest"
|
||||||
|
|
||||||
|
|
||||||
|
def _tunnel_container_name(instance_name: str) -> str:
|
||||||
|
return f"tunnel-{instance_name.lower()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_image() -> None:
|
||||||
|
"""Pull cloudflared image if not already present."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "images", "-q", TUNNEL_IMAGE],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if not result.stdout.strip():
|
||||||
|
logger.info("Pulling %s ...", TUNNEL_IMAGE)
|
||||||
|
pull = subprocess.run(
|
||||||
|
["docker", "pull", TUNNEL_IMAGE],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if pull.returncode != 0:
|
||||||
|
logger.warning("Failed to pull %s: %s", TUNNEL_IMAGE, pull.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_stale_tunnel(tunnel_name: str) -> None:
|
||||||
|
"""Remove any existing tunnel container with this name."""
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "stop", "-t", "3", tunnel_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "rm", "-f", tunnel_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_container_logs(tunnel_name: str) -> tuple[str, str]:
|
||||||
|
"""Get stdout and stderr logs from a container."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "logs", tunnel_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return result.stdout, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def _get_container_exit_code(tunnel_name: str) -> int | None:
|
||||||
|
"""Get exit code of a container if it has exited."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "inspect", "-f", "{{.State.ExitCode}}", tunnel_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
try:
|
||||||
|
return int(result.stdout.strip())
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def start_tunnel(
|
||||||
|
instance_name: str,
|
||||||
|
container_port: int,
|
||||||
|
timeout: int = 30,
|
||||||
|
target_url: str | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Start a temporary Cloudflare tunnel for an instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_name: The tool instance name (used for tunnel naming).
|
||||||
|
container_port: The port the tool container listens on internally.
|
||||||
|
timeout: Seconds to wait for the tunnel URL.
|
||||||
|
target_url: Optional explicit URL to proxy to. If omitted, derives
|
||||||
|
http://{instance_name.lower()}:{container_port}.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'url' and 'container_name'.
|
||||||
|
"""
|
||||||
|
_ensure_image()
|
||||||
|
|
||||||
|
tunnel_name = _tunnel_container_name(instance_name)
|
||||||
|
_cleanup_stale_tunnel(tunnel_name)
|
||||||
|
|
||||||
|
# Target the tool container by name on the backend network
|
||||||
|
if target_url is None:
|
||||||
|
target_url = f"http://{instance_name.lower()}:{container_port}"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--network",
|
||||||
|
get_backend_network_name(),
|
||||||
|
"--name",
|
||||||
|
tunnel_name,
|
||||||
|
TUNNEL_IMAGE,
|
||||||
|
"tunnel",
|
||||||
|
"--no-autoupdate",
|
||||||
|
"--url",
|
||||||
|
target_url,
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.debug("Running: %s", " ".join(cmd))
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to start tunnel container {tunnel_name}: {proc.stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
container_id = proc.stdout.strip()
|
||||||
|
logger.debug("Tunnel container started: %s", container_id)
|
||||||
|
|
||||||
|
# Wait for URL to appear in logs
|
||||||
|
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
||||||
|
start_time = __import__("time").time()
|
||||||
|
url: str | None = None
|
||||||
|
combined_logs = ""
|
||||||
|
|
||||||
|
while __import__("time").time() - start_time < timeout:
|
||||||
|
stdout, stderr = _get_container_logs(tunnel_name)
|
||||||
|
combined_logs = stdout + "\n" + stderr
|
||||||
|
|
||||||
|
match = url_pattern.search(combined_logs)
|
||||||
|
if match:
|
||||||
|
url = match.group(0)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if container exited early
|
||||||
|
exit_code = _get_container_exit_code(tunnel_name)
|
||||||
|
if exit_code is not None and exit_code != 0:
|
||||||
|
_cleanup_stale_tunnel(tunnel_name)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Tunnel container {tunnel_name} exited with code {exit_code}. "
|
||||||
|
f"Logs:\n{combined_logs[-3000:]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
__import__("time").sleep(0.5)
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
stdout, stderr = _get_container_logs(tunnel_name)
|
||||||
|
combined_logs = stdout + "\n" + stderr
|
||||||
|
exit_code = _get_container_exit_code(tunnel_name)
|
||||||
|
|
||||||
|
_cleanup_stale_tunnel(tunnel_name)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Tunnel {tunnel_name} did not produce a URL within {timeout}s. "
|
||||||
|
f"Exit code: {exit_code}. Logs:\n{combined_logs[-3000:]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait a moment for Cloudflare DNS edge to propagate the new tunnel subdomain
|
||||||
|
__import__("time").sleep(2)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Tunnel %s started for %s → %s (%s)",
|
||||||
|
tunnel_name,
|
||||||
|
instance_name,
|
||||||
|
target_url,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
return {"url": url, "container_name": tunnel_name}
|
||||||
|
|
||||||
|
|
||||||
|
def stop_tunnel(instance_name: str) -> None:
|
||||||
|
"""Stop and remove the tunnel container for an instance."""
|
||||||
|
tunnel_name = _tunnel_container_name(instance_name)
|
||||||
|
_cleanup_stale_tunnel(tunnel_name)
|
||||||
|
logger.debug("Stopped and removed tunnel container %s", tunnel_name)
|
||||||
|
|
||||||
|
|
||||||
|
def recreate_tunnel(
|
||||||
|
instance_name: str, container_port: int, target_url: str | None = None
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Recreate a tunnel for an instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance_name: The tool instance name.
|
||||||
|
container_port: The port the tool container listens on internally.
|
||||||
|
target_url: Optional explicit origin URL. If omitted, derives
|
||||||
|
http://{instance_name.lower()}:{container_port}.
|
||||||
|
"""
|
||||||
|
stop_tunnel(instance_name)
|
||||||
|
return start_tunnel(instance_name, container_port, target_url=target_url)
|
||||||
|
|
||||||
|
|
||||||
|
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
||||||
|
"""Check if a tunnel URL is healthy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'tunnel_status', 'status_code', 'healthy', 'error'.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"curl",
|
||||||
|
"-s",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
"-w",
|
||||||
|
"%{http_code}",
|
||||||
|
"--max-time",
|
||||||
|
str(timeout),
|
||||||
|
url,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout + 5,
|
||||||
|
)
|
||||||
|
status_code = int(result.stdout.strip())
|
||||||
|
|
||||||
|
if 200 <= status_code < 400:
|
||||||
|
return {
|
||||||
|
"tunnel_status": "healthy",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": True,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
if status_code in (502, 503, 504):
|
||||||
|
return {
|
||||||
|
"tunnel_status": "error_response",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"Application returned HTTP {status_code}",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"tunnel_status": "error_response",
|
||||||
|
"status_code": status_code,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"HTTP {status_code}",
|
||||||
|
}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": "Tunnel request timed out",
|
||||||
|
}
|
||||||
|
except (ValueError, Exception) as exc:
|
||||||
|
error_str = str(exc).lower()
|
||||||
|
if any(
|
||||||
|
err in error_str
|
||||||
|
for err in [
|
||||||
|
"connection refused",
|
||||||
|
"econnrefused",
|
||||||
|
"could not resolve",
|
||||||
|
"nodename",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": f"Tunnel unreachable: {exc}",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"tunnel_status": "unreachable",
|
||||||
|
"status_code": None,
|
||||||
|
"healthy": False,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Workspace lifecycle management service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
from src.services.git_service import GitService
|
||||||
|
from src.services.ssh_keys import _get_fernet
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SyncResult:
|
||||||
|
"""Result of a workspace sync operation."""
|
||||||
|
|
||||||
|
branch_deleted: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceHasInstancesError(Exception):
|
||||||
|
"""Raised when attempting to delete a workspace with running instances."""
|
||||||
|
|
||||||
|
def __init__(self, instances: list[dict]) -> None:
|
||||||
|
self.instances = instances
|
||||||
|
super().__init__(f"Workspace has {len(instances)} running tool instance(s)")
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceManager:
|
||||||
|
"""Manages workspace lifecycle: create, delete, sync, validate."""
|
||||||
|
|
||||||
|
BASE_PATH = "/data/working-copies"
|
||||||
|
|
||||||
|
def _workspace_path(self, repo_id: uuid.UUID, name: str) -> str:
|
||||||
|
"""Return the filesystem path for a workspace."""
|
||||||
|
return os.path.join(self.BASE_PATH, str(repo_id), name)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
repo: GitRepository,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
name: str,
|
||||||
|
branch: str = "main",
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Workspace:
|
||||||
|
"""Clone repo to workspace path and create DB record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: The git repository to clone.
|
||||||
|
user_id: The owner user ID.
|
||||||
|
name: The workspace name (unique per repo).
|
||||||
|
branch: The branch to clone (default: "main").
|
||||||
|
session: Database session for loading SSH keys.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created Workspace record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If git clone fails.
|
||||||
|
"""
|
||||||
|
path = self._workspace_path(repo.id, name)
|
||||||
|
parent = os.path.dirname(path)
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
# Ensure container users (various UIDs) can write to workspace dirs
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.chmod(parent, 0o777)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Creating workspace: name=%s, repo=%s, branch=%s", name, repo.id, branch
|
||||||
|
)
|
||||||
|
|
||||||
|
if not repo.remote_url:
|
||||||
|
raise ValueError("Repository has no remote URL")
|
||||||
|
|
||||||
|
# Remove stale directory from previous failed/aborted clone
|
||||||
|
if os.path.exists(path):
|
||||||
|
logger.warning("Removing stale workspace directory: %s", path)
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
|
||||||
|
# Load SSH key if repo has one
|
||||||
|
ssh_key = None
|
||||||
|
if getattr(repo, "ssh_key_id", None) and session is not None:
|
||||||
|
from src.models.ssh_key import SSHKey
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(SSHKey).where(SSHKey.id == repo.ssh_key_id)
|
||||||
|
)
|
||||||
|
ssh_key_obj = result.scalar_one_or_none()
|
||||||
|
if ssh_key_obj:
|
||||||
|
fernet = _get_fernet()
|
||||||
|
ssh_key = fernet.decrypt(
|
||||||
|
ssh_key_obj.private_key_encrypted.encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
await GitService.clone(repo.remote_url, branch, path, ssh_key=ssh_key)
|
||||||
|
self._make_world_writable(path)
|
||||||
|
|
||||||
|
workspace = Workspace(
|
||||||
|
name=name,
|
||||||
|
repo_id=repo.id,
|
||||||
|
user_id=user_id,
|
||||||
|
branch=branch,
|
||||||
|
path=path,
|
||||||
|
status="ready",
|
||||||
|
last_sync_at=datetime.now(),
|
||||||
|
)
|
||||||
|
logger.info("Workspace created: %s", workspace.id)
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
async def delete(
|
||||||
|
self,
|
||||||
|
workspace: Workspace,
|
||||||
|
force: bool = False,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Delete a workspace and all associated tool instances.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace: The workspace to delete.
|
||||||
|
force: If True, delete even if instances exist.
|
||||||
|
session: The database session (required for checking instances).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
WorkspaceHasInstancesError: If instances exist and force=False.
|
||||||
|
"""
|
||||||
|
if session is None:
|
||||||
|
raise ValueError("session is required for delete")
|
||||||
|
|
||||||
|
instances = await self._get_instances(workspace, session)
|
||||||
|
if instances and not force:
|
||||||
|
raise WorkspaceHasInstancesError(
|
||||||
|
[{"id": str(i.id), "name": i.name} for i in instances]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stop and delete all instances
|
||||||
|
for instance in instances:
|
||||||
|
await self._stop_and_delete_instance(instance)
|
||||||
|
|
||||||
|
# Delete directory
|
||||||
|
if os.path.exists(workspace.path):
|
||||||
|
shutil.rmtree(workspace.path, ignore_errors=True)
|
||||||
|
logger.info("Deleted workspace directory: %s", workspace.path)
|
||||||
|
|
||||||
|
# Delete record
|
||||||
|
await session.delete(workspace)
|
||||||
|
logger.info("Deleted workspace record: %s", workspace.id)
|
||||||
|
|
||||||
|
async def sync(
|
||||||
|
self, workspace: Workspace, session: AsyncSession | None = None
|
||||||
|
) -> SyncResult:
|
||||||
|
"""Sync a workspace with its remote.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workspace: The workspace to sync.
|
||||||
|
session: Database session for loading SSH keys.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SyncResult indicating whether the branch was deleted.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If git operations fail.
|
||||||
|
"""
|
||||||
|
logger.info("Syncing workspace: %s", workspace.id)
|
||||||
|
|
||||||
|
# Load SSH key if repo has one
|
||||||
|
ssh_key = None
|
||||||
|
if session is not None:
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.ssh_key import SSHKey
|
||||||
|
|
||||||
|
repo = await session.get(GitRepository, workspace.repo_id)
|
||||||
|
if repo and getattr(repo, "ssh_key_id", None):
|
||||||
|
result = await session.execute(
|
||||||
|
select(SSHKey).where(SSHKey.id == repo.ssh_key_id)
|
||||||
|
)
|
||||||
|
ssh_key_obj = result.scalar_one_or_none()
|
||||||
|
if ssh_key_obj:
|
||||||
|
fernet = _get_fernet()
|
||||||
|
ssh_key = fernet.decrypt(
|
||||||
|
ssh_key_obj.private_key_encrypted.encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
await GitService.fetch(workspace.path, ssh_key=ssh_key)
|
||||||
|
|
||||||
|
if not GitService.branch_exists_remotely(
|
||||||
|
workspace.path, workspace.branch, ssh_key=ssh_key
|
||||||
|
):
|
||||||
|
return SyncResult(branch_deleted=True)
|
||||||
|
|
||||||
|
await GitService.pull(workspace.path, workspace.branch, ssh_key=ssh_key)
|
||||||
|
self._make_world_writable(workspace.path)
|
||||||
|
|
||||||
|
workspace.last_sync_at = datetime.now()
|
||||||
|
logger.info("Workspace synced: %s", workspace.id)
|
||||||
|
return SyncResult(branch_deleted=False)
|
||||||
|
|
||||||
|
def _make_world_writable(self, path: str) -> None:
|
||||||
|
"""Recursively make path readable/writable/traversable by any UID.
|
||||||
|
|
||||||
|
Directories get 777 (traversable). Files get rw for all while
|
||||||
|
preserving any existing execute bits.
|
||||||
|
"""
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.chmod(path, 0o777)
|
||||||
|
for root, dirs, files in os.walk(path):
|
||||||
|
for d in dirs:
|
||||||
|
dpath = os.path.join(root, d)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.chmod(dpath, 0o777)
|
||||||
|
for f in files:
|
||||||
|
fpath = os.path.join(root, f)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
mode = os.stat(fpath).st_mode
|
||||||
|
# Preserve execute bits, ensure read+write for all
|
||||||
|
new_mode = (mode & stat.S_IXUSR) | 0o666
|
||||||
|
if mode & stat.S_IXGRP:
|
||||||
|
new_mode |= stat.S_IXGRP
|
||||||
|
if mode & stat.S_IXOTH:
|
||||||
|
new_mode |= stat.S_IXOTH
|
||||||
|
os.chmod(fpath, new_mode)
|
||||||
|
|
||||||
|
async def _get_instances(
|
||||||
|
self,
|
||||||
|
workspace: Workspace,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> list[ToolInstance]:
|
||||||
|
"""Get all tool instances associated with this workspace."""
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance).where(ToolInstance.workspace_id == workspace.id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def _stop_and_delete_instance(self, instance: ToolInstance) -> None:
|
||||||
|
"""Stop and delete a tool instance.
|
||||||
|
|
||||||
|
TODO(PR-2): Wire up to actual instance stop/delete logic.
|
||||||
|
For now, this is a placeholder.
|
||||||
|
"""
|
||||||
|
logger.warning("Placeholder: stopping and deleting instance %s", instance.id)
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"""Integration tests for SSE endpoint and lifecycle event flow."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Generator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api import events as events_module
|
||||||
|
from src.auth.session import decode_session_cookie
|
||||||
|
from src.config import Settings
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.instance_event import InstanceEvent
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def event_bus() -> Generator[InstanceEventBus, None, None]:
|
||||||
|
bus = InstanceEventBus()
|
||||||
|
bus._reset_for_testing()
|
||||||
|
yield bus
|
||||||
|
bus._reset_for_testing()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_payload() -> InstanceEventPayload:
|
||||||
|
return {
|
||||||
|
"event": "instance.started",
|
||||||
|
"instance_id": str(uuid.uuid4()),
|
||||||
|
"status": "starting",
|
||||||
|
"message": "Container starting...",
|
||||||
|
"metadata": {},
|
||||||
|
"timestamp": "2026-05-28T12:00:00Z",
|
||||||
|
"correlation_id": str(uuid.uuid4()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_id_from_client(client: TestClient) -> uuid.UUID | None:
|
||||||
|
settings = Settings()
|
||||||
|
cookie = client.cookies.get("session")
|
||||||
|
if not cookie:
|
||||||
|
return None
|
||||||
|
session = decode_session_cookie(settings=settings, cookie_value=cookie)
|
||||||
|
if session and "user_id" in session:
|
||||||
|
return uuid.UUID(session["user_id"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_sse_requires_auth(test_client: TestClient) -> None:
|
||||||
|
response = test_client.get("/events/stream")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_sse_enforces_connection_limit(authenticated_client: TestClient) -> None:
|
||||||
|
user_id = _get_user_id_from_client(authenticated_client)
|
||||||
|
assert user_id is not None
|
||||||
|
|
||||||
|
events_module._connection_counts[user_id] = events_module.MAX_CONNECTIONS_PER_USER
|
||||||
|
try:
|
||||||
|
response = authenticated_client.get("/events/stream")
|
||||||
|
assert response.status_code == 429
|
||||||
|
finally:
|
||||||
|
events_module._connection_counts.pop(user_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_sse_event_generator_format() -> None:
|
||||||
|
"""Test the SSE endpoint is registered."""
|
||||||
|
from src.api.events import router
|
||||||
|
|
||||||
|
route_paths = [getattr(r, "path", "") for r in router.routes]
|
||||||
|
assert any("/stream" in str(p) for p in route_paths)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_lifecycle_hook_publishes_event_and_persists(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
) -> None:
|
||||||
|
"""Test that the lifecycle hook publishes an event and persists an audit row."""
|
||||||
|
user_id = _get_user_id_from_client(authenticated_client)
|
||||||
|
assert user_id is not None
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-project",
|
||||||
|
description="Test",
|
||||||
|
owner_id=user_id,
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-repo",
|
||||||
|
path="/tmp/test-repo",
|
||||||
|
project_id=project.id,
|
||||||
|
owner_id=user_id,
|
||||||
|
remote_url="https://github.com/test/repo.git",
|
||||||
|
)
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-tool",
|
||||||
|
display_name="Test Tool",
|
||||||
|
category="other",
|
||||||
|
interface_type="web",
|
||||||
|
requires_port=True,
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="legacy",
|
||||||
|
compose_template="version: '3.8'\nservices:\n app:\n image: alpine\n command: sleep 3600\n",
|
||||||
|
)
|
||||||
|
db_session.add_all([project, repo, tool_type])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-instance",
|
||||||
|
display_name="Test Instance",
|
||||||
|
tool_type_id=tool_type.id,
|
||||||
|
repository_id=repo.id,
|
||||||
|
project_id=project.id,
|
||||||
|
owner_id=user_id,
|
||||||
|
status="pending",
|
||||||
|
compose_path="/tmp/test-compose.yml",
|
||||||
|
port=8080,
|
||||||
|
)
|
||||||
|
db_session.add(instance)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
received: list[Any] = []
|
||||||
|
|
||||||
|
def subscriber(payload: InstanceEventPayload) -> None:
|
||||||
|
received.append(payload)
|
||||||
|
|
||||||
|
event_bus.subscribe("instance.created", subscriber)
|
||||||
|
|
||||||
|
from src.services.lifecycle_hooks import publish_lifecycle_event
|
||||||
|
|
||||||
|
await publish_lifecycle_event(
|
||||||
|
event_bus=event_bus,
|
||||||
|
session=db_session,
|
||||||
|
instance=instance,
|
||||||
|
event_type="instance.created",
|
||||||
|
created_by=user_id,
|
||||||
|
status="pending",
|
||||||
|
message="Instance created",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0]["event"] == "instance.created"
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(InstanceEvent).where(InstanceEvent.instance_id == instance.id)
|
||||||
|
)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].event_type == "created"
|
||||||
|
assert rows[0].created_by == user_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_lifecycle_event_persists_audit_row(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
) -> None:
|
||||||
|
"""Test that publishing a lifecycle event persists an audit row."""
|
||||||
|
user_id = _get_user_id_from_client(authenticated_client)
|
||||||
|
assert user_id is not None
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-project",
|
||||||
|
description="Test",
|
||||||
|
owner_id=user_id,
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-repo",
|
||||||
|
path="/tmp/test-repo",
|
||||||
|
project_id=project.id,
|
||||||
|
owner_id=user_id,
|
||||||
|
remote_url="https://github.com/test/repo.git",
|
||||||
|
)
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-tool-2",
|
||||||
|
display_name="Test Tool 2",
|
||||||
|
category="other",
|
||||||
|
interface_type="web",
|
||||||
|
requires_port=True,
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="legacy",
|
||||||
|
compose_template="version: '3.8'\nservices:\n app:\n image: alpine\n command: sleep 3600\n",
|
||||||
|
)
|
||||||
|
db_session.add_all([project, repo, tool_type])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-instance",
|
||||||
|
display_name="Test Instance",
|
||||||
|
tool_type_id=tool_type.id,
|
||||||
|
repository_id=repo.id,
|
||||||
|
project_id=project.id,
|
||||||
|
owner_id=user_id,
|
||||||
|
status="running",
|
||||||
|
compose_path="/tmp/test-compose.yml",
|
||||||
|
port=8080,
|
||||||
|
)
|
||||||
|
db_session.add(instance)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
from src.services.lifecycle_hooks import publish_lifecycle_event
|
||||||
|
|
||||||
|
await publish_lifecycle_event(
|
||||||
|
event_bus=event_bus,
|
||||||
|
session=db_session,
|
||||||
|
instance=instance,
|
||||||
|
event_type="instance.stopped",
|
||||||
|
created_by=user_id,
|
||||||
|
status="stopped",
|
||||||
|
message="Instance stopped",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(InstanceEvent).where(InstanceEvent.instance_id == instance.id)
|
||||||
|
)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].event_type == "stopped"
|
||||||
|
assert rows[0].status == "stopped"
|
||||||
|
assert rows[0].created_by == user_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_event_bus_pubsub(event_bus: InstanceEventBus) -> None:
|
||||||
|
"""Test that the event bus delivers events to subscribers."""
|
||||||
|
received: list[InstanceEventPayload] = []
|
||||||
|
|
||||||
|
def handler(payload: InstanceEventPayload) -> None:
|
||||||
|
received.append(payload)
|
||||||
|
|
||||||
|
event_bus.subscribe("test.event", handler)
|
||||||
|
|
||||||
|
payload: InstanceEventPayload = {
|
||||||
|
"event": "test.event",
|
||||||
|
"instance_id": str(uuid.uuid4()),
|
||||||
|
"status": "running",
|
||||||
|
"message": "Test",
|
||||||
|
"metadata": {},
|
||||||
|
"timestamp": "2026-05-28T12:00:00Z",
|
||||||
|
"correlation_id": str(uuid.uuid4()),
|
||||||
|
}
|
||||||
|
|
||||||
|
asyncio.run(event_bus.publish("test.event", payload))
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0]["event"] == "test.event"
|
||||||
@@ -16,7 +16,6 @@ def test_base_metadata_collects_declared_tables() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
||||||
def test_shared_mixins_define_expected_columns() -> None:
|
def test_shared_mixins_define_expected_columns() -> None:
|
||||||
assert "id" in UUIDPrimaryKeyMixin.__dict__
|
assert "id" in UUIDPrimaryKeyMixin.__dict__
|
||||||
assert "created_at" in TimestampMixin.__dict__
|
assert "created_at" in TimestampMixin.__dict__
|
||||||
@@ -24,20 +23,26 @@ def test_shared_mixins_define_expected_columns() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
||||||
def test_expected_tables_are_registered() -> None:
|
def test_expected_tables_are_registered() -> None:
|
||||||
assert set(Base.metadata.tables) == {
|
assert set(Base.metadata.tables) == {
|
||||||
"refresh_tokens",
|
"config_profile_includes",
|
||||||
|
"config_profiles",
|
||||||
"git_repositories",
|
"git_repositories",
|
||||||
|
"health_checks",
|
||||||
|
"instance_events",
|
||||||
|
"notifications",
|
||||||
"projects",
|
"projects",
|
||||||
"ssh_keys",
|
"ssh_keys",
|
||||||
|
"terminal_sessions",
|
||||||
|
"tool_definition_manifests",
|
||||||
|
"tool_instances",
|
||||||
|
"tool_types",
|
||||||
"user_configs",
|
"user_configs",
|
||||||
"users",
|
"users",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
||||||
def test_user_table_has_required_columns() -> None:
|
def test_user_table_has_required_columns() -> None:
|
||||||
columns = User.__table__.columns
|
columns = User.__table__.columns
|
||||||
|
|
||||||
@@ -56,7 +61,6 @@ def test_user_table_has_required_columns() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
||||||
def test_project_relationships_point_to_owner_and_default_ssh_key() -> None:
|
def test_project_relationships_point_to_owner_and_default_ssh_key() -> None:
|
||||||
owner_fk = next(iter(Project.__table__.c.owner_id.foreign_keys))
|
owner_fk = next(iter(Project.__table__.c.owner_id.foreign_keys))
|
||||||
ssh_fk = next(iter(Project.__table__.c.default_ssh_key_id.foreign_keys))
|
ssh_fk = next(iter(Project.__table__.c.default_ssh_key_id.foreign_keys))
|
||||||
@@ -68,7 +72,6 @@ def test_project_relationships_point_to_owner_and_default_ssh_key() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
||||||
def test_repository_and_user_config_relationships_are_registered() -> None:
|
def test_repository_and_user_config_relationships_are_registered() -> None:
|
||||||
project_fk = next(iter(GitRepository.__table__.c.project_id.foreign_keys))
|
project_fk = next(iter(GitRepository.__table__.c.project_id.foreign_keys))
|
||||||
owner_fk = next(iter(GitRepository.__table__.c.owner_id.foreign_keys))
|
owner_fk = next(iter(GitRepository.__table__.c.owner_id.foreign_keys))
|
||||||
@@ -84,9 +87,13 @@ def test_repository_and_user_config_relationships_are_registered() -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
||||||
async def test_async_session_can_insert_and_load_user(db_session: AsyncSession) -> None:
|
async def test_async_session_can_insert_and_load_user(db_session: AsyncSession) -> None:
|
||||||
user = User(email="dev@headquarter.local", name="Dev User", authentik_id="dev-user", avatar_url=None)
|
user = User(
|
||||||
|
email="dev@headquarter.local",
|
||||||
|
name="Dev User",
|
||||||
|
authentik_id="dev-user",
|
||||||
|
avatar_url=None,
|
||||||
|
)
|
||||||
|
|
||||||
db_session.add(user)
|
db_session.add(user)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""Integration tests for notifications API."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.user import User
|
||||||
|
from src.models.user_config import UserConfig
|
||||||
|
from src.services.notification_service import NotificationService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def notification_service() -> NotificationService:
|
||||||
|
return NotificationService()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def user_a(db_session: AsyncSession) -> User:
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="user-a@headquarter.local",
|
||||||
|
name="User A",
|
||||||
|
authentik_id=f"authentik-{uuid.uuid4()}",
|
||||||
|
avatar_url=None,
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def user_b(db_session: AsyncSession) -> User:
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="user-b@headquarter.local",
|
||||||
|
name="User B",
|
||||||
|
authentik_id=f"authentik-{uuid.uuid4()}",
|
||||||
|
avatar_url=None,
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _mint_cookie_for_user(test_client: TestClient, user_id: uuid.UUID) -> None:
|
||||||
|
from src.auth.session import create_session_cookie
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
cookie = create_session_cookie(
|
||||||
|
settings=settings,
|
||||||
|
user_id=str(user_id),
|
||||||
|
)
|
||||||
|
test_client.cookies.set("session", cookie)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_list_requires_auth(test_client: TestClient) -> None:
|
||||||
|
response = test_client.get("/notifications")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_list_returns_only_own_notifications(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
user_b: User,
|
||||||
|
) -> None:
|
||||||
|
async def create_notifications() -> None:
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title="A"
|
||||||
|
)
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session, user_b.id, category="instance", severity="info", title="B"
|
||||||
|
)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(create_notifications())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||||
|
response = authenticated_client.get("/notifications")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["items"]) == 1
|
||||||
|
assert data["items"][0]["title"] == "A"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_list_pagination(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
async def create_many() -> None:
|
||||||
|
for i in range(25):
|
||||||
|
n = await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title=f"Notification {i}",
|
||||||
|
)
|
||||||
|
n.created_at = datetime.now(timezone.utc) - timedelta(seconds=i)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(n)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(create_many())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||||
|
response = authenticated_client.get("/notifications?limit=10&offset=10")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["items"]) == 10
|
||||||
|
assert data["total"] == 25
|
||||||
|
assert data["limit"] == 10
|
||||||
|
assert data["offset"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_unread_count_endpoint(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
async def create_unread() -> None:
|
||||||
|
for _ in range(3):
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title="Unread",
|
||||||
|
)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(create_unread())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||||
|
response = authenticated_client.get("/notifications/unread")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["count"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_mark_read_endpoint(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
async def create_and_get() -> uuid.UUID:
|
||||||
|
n = await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title="To read"
|
||||||
|
)
|
||||||
|
return n.id
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
nid = asyncio.run(create_and_get())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||||
|
response = authenticated_client.patch(f"/notifications/{nid}/read")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["read_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_mark_read_404_for_other_user(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
user_b: User,
|
||||||
|
) -> None:
|
||||||
|
async def create_and_get() -> uuid.UUID:
|
||||||
|
n = await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title="Owned by A",
|
||||||
|
)
|
||||||
|
return n.id
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
nid = asyncio.run(create_and_get())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_b.id)
|
||||||
|
response = authenticated_client.patch(f"/notifications/{nid}/read")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_mark_all_read_endpoint(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
async def create_unread() -> None:
|
||||||
|
for _ in range(4):
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title="Unread",
|
||||||
|
)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(create_unread())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||||
|
response = authenticated_client.post("/notifications/mark-all-read")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["marked_count"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_dismiss_endpoint(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
async def create_and_get() -> uuid.UUID:
|
||||||
|
n = await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title="To dismiss",
|
||||||
|
)
|
||||||
|
return n.id
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
nid = asyncio.run(create_and_get())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||||
|
response = authenticated_client.delete(f"/notifications/{nid}")
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
response = authenticated_client.get("/notifications")
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_dismiss_404_for_other_user(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
user_b: User,
|
||||||
|
) -> None:
|
||||||
|
async def create_and_get() -> uuid.UUID:
|
||||||
|
n = await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title="Owned by A",
|
||||||
|
)
|
||||||
|
return n.id
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
nid = asyncio.run(create_and_get())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_b.id)
|
||||||
|
response = authenticated_client.delete(f"/notifications/{nid}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_mute_categories_filter_in_list(
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
async def setup() -> None:
|
||||||
|
config = UserConfig(
|
||||||
|
user_id=user_a.id, config={"notification_mute_categories": ["instance"]}
|
||||||
|
)
|
||||||
|
db_session.add(config)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title="Instance",
|
||||||
|
)
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="system", severity="info", title="System"
|
||||||
|
)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
asyncio.run(setup())
|
||||||
|
|
||||||
|
_mint_cookie_for_user(authenticated_client, user_a.id)
|
||||||
|
response = authenticated_client.get("/notifications")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["items"]) == 1
|
||||||
|
assert data["items"][0]["title"] == "System"
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
"""Integration tests for event producer → notification creation flow."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Generator
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.notification import Notification
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.user import User
|
||||||
|
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
|
from src.services.health_monitor import HealthSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def event_bus() -> Generator[InstanceEventBus, None, None]:
|
||||||
|
"""Provide a fresh EventBus instance."""
|
||||||
|
bus = InstanceEventBus()
|
||||||
|
bus._reset_for_testing()
|
||||||
|
yield bus
|
||||||
|
bus._reset_for_testing()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_instance(db_session: AsyncSession) -> ToolInstance:
|
||||||
|
"""Create a complete tool instance with all required relations."""
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="owner@headquarter.local",
|
||||||
|
name="Owner",
|
||||||
|
authentik_id=f"authentik-{uuid.uuid4()}",
|
||||||
|
avatar_url=None,
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-project",
|
||||||
|
description="Test",
|
||||||
|
owner_id=user.id,
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-repo",
|
||||||
|
path="/tmp/test-repo",
|
||||||
|
project_id=project.id,
|
||||||
|
owner_id=user.id,
|
||||||
|
remote_url="https://github.com/test/repo.git",
|
||||||
|
)
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-tool",
|
||||||
|
display_name="Test Tool",
|
||||||
|
category="other",
|
||||||
|
interface_type="web",
|
||||||
|
requires_port=True,
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="legacy",
|
||||||
|
compose_template="version: '3.8'\nservices:\n app:\n image: alpine\n command: sleep 3600\n",
|
||||||
|
)
|
||||||
|
db_session.add_all([project, repo, tool_type])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-instance",
|
||||||
|
display_name="Test Instance",
|
||||||
|
tool_type_id=tool_type.id,
|
||||||
|
repository_id=repo.id,
|
||||||
|
project_id=project.id,
|
||||||
|
owner_id=user.id,
|
||||||
|
status="running",
|
||||||
|
compose_path="/tmp/test-compose.yml",
|
||||||
|
port=8080,
|
||||||
|
)
|
||||||
|
db_session.add(instance)
|
||||||
|
await db_session.commit()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_lifecycle_started_intermediate_skips_notification(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
test_instance: ToolInstance,
|
||||||
|
) -> None:
|
||||||
|
"""Intermediate 'starting' state does NOT create a notification."""
|
||||||
|
received: list[InstanceEventPayload] = []
|
||||||
|
|
||||||
|
def subscriber(payload: InstanceEventPayload) -> None:
|
||||||
|
received.append(payload)
|
||||||
|
|
||||||
|
event_bus.subscribe("instance.started", subscriber)
|
||||||
|
|
||||||
|
from src.services.lifecycle_hooks import publish_lifecycle_event
|
||||||
|
|
||||||
|
await publish_lifecycle_event(
|
||||||
|
event_bus=event_bus,
|
||||||
|
session=db_session,
|
||||||
|
instance=test_instance,
|
||||||
|
event_type="instance.started",
|
||||||
|
status="starting",
|
||||||
|
message="Container starting...",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Event still published
|
||||||
|
assert len(received) == 1
|
||||||
|
|
||||||
|
# No notification created for intermediate state
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(Notification).where(Notification.user_id == test_instance.owner_id)
|
||||||
|
)
|
||||||
|
notifications = list(result.scalars().all())
|
||||||
|
assert len(notifications) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_lifecycle_running_creates_notification(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
test_instance: ToolInstance,
|
||||||
|
) -> None:
|
||||||
|
"""Successful terminal state (running) creates a notification."""
|
||||||
|
from src.services.lifecycle_hooks import publish_lifecycle_event
|
||||||
|
|
||||||
|
await publish_lifecycle_event(
|
||||||
|
event_bus=event_bus,
|
||||||
|
session=db_session,
|
||||||
|
instance=test_instance,
|
||||||
|
event_type="instance.health_changed",
|
||||||
|
status="running",
|
||||||
|
message="Container running",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(Notification).where(Notification.user_id == test_instance.owner_id)
|
||||||
|
)
|
||||||
|
notifications = list(result.scalars().all())
|
||||||
|
assert len(notifications) == 1
|
||||||
|
n = notifications[0]
|
||||||
|
assert n.category == "instance"
|
||||||
|
assert n.severity == "success"
|
||||||
|
assert n.title == "Container ready"
|
||||||
|
assert n.source_type == "tool_instances"
|
||||||
|
assert n.source_id == test_instance.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_health_monitor_error_creates_notification(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
test_instance: ToolInstance,
|
||||||
|
) -> None:
|
||||||
|
"""Simulating a health monitor crash creates an error notification."""
|
||||||
|
from src.services.health_monitor import HealthMonitor
|
||||||
|
|
||||||
|
monitor = HealthMonitor(event_bus)
|
||||||
|
|
||||||
|
received: list[InstanceEventPayload] = []
|
||||||
|
|
||||||
|
def subscriber(payload: InstanceEventPayload) -> None:
|
||||||
|
received.append(payload)
|
||||||
|
|
||||||
|
event_bus.subscribe("instance.error", subscriber)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.services.health_monitor.get_container_status",
|
||||||
|
return_value={"status": "exited", "exit_code": 137, "health": None},
|
||||||
|
):
|
||||||
|
await monitor._check_instance(db_session, test_instance)
|
||||||
|
|
||||||
|
# Event published
|
||||||
|
assert len(received) == 1
|
||||||
|
|
||||||
|
# Notification created
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(Notification).where(Notification.user_id == test_instance.owner_id)
|
||||||
|
)
|
||||||
|
notifications = list(result.scalars().all())
|
||||||
|
assert len(notifications) == 1
|
||||||
|
n = notifications[0]
|
||||||
|
assert n.category == "instance"
|
||||||
|
assert n.severity == "error"
|
||||||
|
assert n.source_type == "tool_instances"
|
||||||
|
assert n.source_id == test_instance.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_notification_failure_does_not_block_event_pipeline(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
test_instance: ToolInstance,
|
||||||
|
) -> None:
|
||||||
|
"""If NotificationService raises, the event is still published and no exception escapes."""
|
||||||
|
received: list[InstanceEventPayload] = []
|
||||||
|
|
||||||
|
def subscriber(payload: InstanceEventPayload) -> None:
|
||||||
|
received.append(payload)
|
||||||
|
|
||||||
|
event_bus.subscribe("instance.started", subscriber)
|
||||||
|
|
||||||
|
from src.services.lifecycle_hooks import publish_lifecycle_event
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.services.lifecycle_hooks.notification_service.create_notification",
|
||||||
|
side_effect=RuntimeError("DB is down"),
|
||||||
|
):
|
||||||
|
# Should not raise
|
||||||
|
await publish_lifecycle_event(
|
||||||
|
event_bus=event_bus,
|
||||||
|
session=db_session,
|
||||||
|
instance=test_instance,
|
||||||
|
event_type="instance.started",
|
||||||
|
status="starting",
|
||||||
|
message="Container started",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0]["event"] == "instance.started"
|
||||||
|
|
||||||
|
# No notification should have been created
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(Notification).where(Notification.user_id == test_instance.owner_id)
|
||||||
|
)
|
||||||
|
assert result.scalar_one_or_none() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_notification_ownership_matches_instance_owner(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
) -> None:
|
||||||
|
"""Notification user_id matches the instance owner, not any caller."""
|
||||||
|
# Create a caller user (simulates the user making an API request)
|
||||||
|
caller = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="caller@headquarter.local",
|
||||||
|
name="Caller",
|
||||||
|
authentik_id=f"authentik-{uuid.uuid4()}",
|
||||||
|
avatar_url=None,
|
||||||
|
)
|
||||||
|
db_session.add(caller)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Create the actual owner
|
||||||
|
owner = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="owner@headquarter.local",
|
||||||
|
name="Owner",
|
||||||
|
authentik_id=f"authentik-{uuid.uuid4()}",
|
||||||
|
avatar_url=None,
|
||||||
|
)
|
||||||
|
db_session.add(owner)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-project",
|
||||||
|
description="Test",
|
||||||
|
owner_id=owner.id,
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-repo",
|
||||||
|
path="/tmp/test-repo",
|
||||||
|
project_id=project.id,
|
||||||
|
owner_id=owner.id,
|
||||||
|
remote_url="https://github.com/test/repo.git",
|
||||||
|
)
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-tool",
|
||||||
|
display_name="Test Tool",
|
||||||
|
category="other",
|
||||||
|
interface_type="web",
|
||||||
|
requires_port=True,
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="legacy",
|
||||||
|
compose_template="version: '3.8'\nservices:\n app:\n image: alpine\n command: sleep 3600\n",
|
||||||
|
)
|
||||||
|
db_session.add_all([project, repo, tool_type])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-instance",
|
||||||
|
display_name="Test Instance",
|
||||||
|
tool_type_id=tool_type.id,
|
||||||
|
repository_id=repo.id,
|
||||||
|
project_id=project.id,
|
||||||
|
owner_id=owner.id,
|
||||||
|
status="running",
|
||||||
|
compose_path="/tmp/test-compose.yml",
|
||||||
|
port=8080,
|
||||||
|
)
|
||||||
|
db_session.add(instance)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
from src.services.lifecycle_hooks import publish_lifecycle_event
|
||||||
|
|
||||||
|
await publish_lifecycle_event(
|
||||||
|
event_bus=event_bus,
|
||||||
|
session=db_session,
|
||||||
|
instance=instance,
|
||||||
|
event_type="instance.health_changed",
|
||||||
|
status="running",
|
||||||
|
message="Container running",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(Notification).where(Notification.source_id == instance.id)
|
||||||
|
)
|
||||||
|
n = result.scalar_one()
|
||||||
|
assert n.user_id == owner.id
|
||||||
|
assert n.user_id != caller.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_lifecycle_error_creates_error_notification(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
test_instance: ToolInstance,
|
||||||
|
) -> None:
|
||||||
|
"""An instance.error lifecycle event creates a severity=error notification."""
|
||||||
|
from src.services.lifecycle_hooks import publish_lifecycle_event
|
||||||
|
|
||||||
|
await publish_lifecycle_event(
|
||||||
|
event_bus=event_bus,
|
||||||
|
session=db_session,
|
||||||
|
instance=test_instance,
|
||||||
|
event_type="instance.error",
|
||||||
|
status="error",
|
||||||
|
message="Container failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(Notification).where(Notification.user_id == test_instance.owner_id)
|
||||||
|
)
|
||||||
|
n = result.scalar_one()
|
||||||
|
assert n.severity == "error"
|
||||||
|
assert n.title == "Container error"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_health_monitor_unhealthy_creates_warning_notification(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
test_instance: ToolInstance,
|
||||||
|
) -> None:
|
||||||
|
"""Health monitor marking instance unhealthy creates severity=warning notification."""
|
||||||
|
from src.services.health_monitor import HealthMonitor
|
||||||
|
|
||||||
|
monitor = HealthMonitor(event_bus)
|
||||||
|
monitor._last_known_state[test_instance.id] = HealthSnapshot(
|
||||||
|
container_status="running",
|
||||||
|
container_healthy=None,
|
||||||
|
tunnel_healthy=True,
|
||||||
|
exit_code=None,
|
||||||
|
)
|
||||||
|
test_instance.public_url = "https://example.trycloudflare.com"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"src.services.health_monitor.get_container_status",
|
||||||
|
return_value={"status": "running", "exit_code": None, "health": "healthy"},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"src.services.health_monitor.check_tunnel_health",
|
||||||
|
return_value={"healthy": False, "tunnel_status": "error_response"},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await monitor._check_instance(db_session, test_instance)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(Notification).where(Notification.user_id == test_instance.owner_id)
|
||||||
|
)
|
||||||
|
n = result.scalar_one()
|
||||||
|
assert n.category == "health"
|
||||||
|
assert n.severity == "warning"
|
||||||
|
assert n.title == "Container unhealthy"
|
||||||
@@ -6,7 +6,9 @@ from fastapi.testclient import TestClient
|
|||||||
class TestToolTypesAPIExtended:
|
class TestToolTypesAPIExtended:
|
||||||
"""Integration tests for tool types API with new fields."""
|
"""Integration tests for tool types API with new fields."""
|
||||||
|
|
||||||
def test_create_tool_type_with_dockerfile(self, authenticated_client: TestClient) -> None:
|
def test_create_tool_type_with_dockerfile(
|
||||||
|
self, authenticated_client: TestClient
|
||||||
|
) -> None:
|
||||||
"""Test creating a tool type with dockerfile definition."""
|
"""Test creating a tool type with dockerfile definition."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
@@ -27,7 +29,9 @@ class TestToolTypesAPIExtended:
|
|||||||
assert data["definition_type"] == "dockerfile"
|
assert data["definition_type"] == "dockerfile"
|
||||||
assert data["dockerfile_template"] == "FROM python:3.11\nRUN pip install flask"
|
assert data["dockerfile_template"] == "FROM python:3.11\nRUN pip install flask"
|
||||||
|
|
||||||
def test_create_tool_type_with_readiness_probe(self, authenticated_client: TestClient) -> None:
|
def test_create_tool_type_with_readiness_probe(
|
||||||
|
self, authenticated_client: TestClient
|
||||||
|
) -> None:
|
||||||
"""Test creating a tool type with readiness probe."""
|
"""Test creating a tool type with readiness probe."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
@@ -52,7 +56,9 @@ class TestToolTypesAPIExtended:
|
|||||||
assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080"
|
assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080"
|
||||||
assert data["readiness_probe"]["timeout"] == 30
|
assert data["readiness_probe"]["timeout"] == 30
|
||||||
|
|
||||||
def test_create_tool_type_invalid_definition_type(self, authenticated_client: TestClient) -> None:
|
def test_create_tool_type_invalid_definition_type(
|
||||||
|
self, authenticated_client: TestClient
|
||||||
|
) -> None:
|
||||||
"""Test that invalid definition types are rejected."""
|
"""Test that invalid definition types are rejected."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
@@ -67,7 +73,9 @@ class TestToolTypesAPIExtended:
|
|||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_create_tool_type_dockerfile_without_template(self, authenticated_client: TestClient) -> None:
|
def test_create_tool_type_dockerfile_without_template(
|
||||||
|
self, authenticated_client: TestClient
|
||||||
|
) -> None:
|
||||||
"""Test that dockerfile type requires dockerfile_template."""
|
"""Test that dockerfile type requires dockerfile_template."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
@@ -81,7 +89,9 @@ class TestToolTypesAPIExtended:
|
|||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
def test_update_tool_type_with_new_fields(self, authenticated_client: TestClient) -> None:
|
def test_update_tool_type_with_new_fields(
|
||||||
|
self, authenticated_client: TestClient
|
||||||
|
) -> None:
|
||||||
"""Test updating a tool type with new fields."""
|
"""Test updating a tool type with new fields."""
|
||||||
# Create tool type first
|
# Create tool type first
|
||||||
create_response = authenticated_client.post(
|
create_response = authenticated_client.post(
|
||||||
@@ -112,7 +122,9 @@ class TestToolTypesAPIExtended:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["display_name"] == "Updated Name"
|
assert data["display_name"] == "Updated Name"
|
||||||
assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080/health"
|
assert (
|
||||||
|
data["readiness_probe"]["command"] == "curl -f http://localhost:8080/health"
|
||||||
|
)
|
||||||
|
|
||||||
def test_validate_tool_type_compose(self, authenticated_client: TestClient) -> None:
|
def test_validate_tool_type_compose(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test validating compose template."""
|
"""Test validating compose template."""
|
||||||
@@ -127,7 +139,9 @@ class TestToolTypesAPIExtended:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["valid"] is True
|
assert data["valid"] is True
|
||||||
|
|
||||||
def test_validate_tool_type_invalid_compose(self, authenticated_client: TestClient) -> None:
|
def test_validate_tool_type_invalid_compose(
|
||||||
|
self, authenticated_client: TestClient
|
||||||
|
) -> None:
|
||||||
"""Test validating invalid compose template."""
|
"""Test validating invalid compose template."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types/validate",
|
"/tool-types/validate",
|
||||||
@@ -141,7 +155,9 @@ class TestToolTypesAPIExtended:
|
|||||||
assert data["valid"] is False
|
assert data["valid"] is False
|
||||||
assert "errors" in data
|
assert "errors" in data
|
||||||
|
|
||||||
def test_validate_tool_type_dockerfile(self, authenticated_client: TestClient) -> None:
|
def test_validate_tool_type_dockerfile(
|
||||||
|
self, authenticated_client: TestClient
|
||||||
|
) -> None:
|
||||||
"""Test validating dockerfile template."""
|
"""Test validating dockerfile template."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types/validate",
|
"/tool-types/validate",
|
||||||
@@ -154,7 +170,9 @@ class TestToolTypesAPIExtended:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["valid"] is True
|
assert data["valid"] is True
|
||||||
|
|
||||||
def test_get_tool_type_returns_new_fields(self, authenticated_client: TestClient) -> None:
|
def test_get_tool_type_returns_new_fields(
|
||||||
|
self, authenticated_client: TestClient
|
||||||
|
) -> None:
|
||||||
"""Test that GET returns new fields."""
|
"""Test that GET returns new fields."""
|
||||||
# Create tool type with all fields
|
# Create tool type with all fields
|
||||||
create_response = authenticated_client.post(
|
create_response = authenticated_client.post(
|
||||||
@@ -166,7 +184,7 @@ class TestToolTypesAPIExtended:
|
|||||||
"interfaces": ["web", "terminal"],
|
"interfaces": ["web", "terminal"],
|
||||||
"default_port": 8443,
|
"default_port": 8443,
|
||||||
"definition_type": "compose",
|
"definition_type": "compose",
|
||||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
|
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n command: --bind-addr 0.0.0.0:8443\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
|
||||||
"readiness_probe": {
|
"readiness_probe": {
|
||||||
"command": "curl -f http://localhost:8443",
|
"command": "curl -f http://localhost:8443",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
@@ -186,7 +204,9 @@ class TestToolTypesAPIExtended:
|
|||||||
assert data["interfaces"] == ["web", "terminal"]
|
assert data["interfaces"] == ["web", "terminal"]
|
||||||
assert "readiness_probe" in data
|
assert "readiness_probe" in data
|
||||||
|
|
||||||
def test_create_tool_type_without_port_fails(self, authenticated_client: TestClient) -> None:
|
def test_create_tool_type_without_port_fails(
|
||||||
|
self, authenticated_client: TestClient
|
||||||
|
) -> None:
|
||||||
"""Test that creating a tool type without default_port fails validation."""
|
"""Test that creating a tool type without default_port fails validation."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
@@ -204,7 +224,9 @@ class TestToolTypesAPIExtended:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert "default_port" in str(data)
|
assert "default_port" in str(data)
|
||||||
|
|
||||||
def test_create_tool_type_with_port_mismatch_fails(self, authenticated_client: TestClient) -> None:
|
def test_create_tool_type_with_port_mismatch_fails(
|
||||||
|
self, authenticated_client: TestClient
|
||||||
|
) -> None:
|
||||||
"""Test that port mismatch between default_port and compose template fails."""
|
"""Test that port mismatch between default_port and compose template fails."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
@@ -222,7 +244,9 @@ class TestToolTypesAPIExtended:
|
|||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
_ = response.json()
|
_ = response.json()
|
||||||
|
|
||||||
def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None:
|
def test_create_tool_type_with_startup_command(
|
||||||
|
self, authenticated_client: TestClient
|
||||||
|
) -> None:
|
||||||
"""Test creating a tool type with startup_command."""
|
"""Test creating a tool type with startup_command."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
@@ -244,7 +268,9 @@ class TestToolTypesAPIExtended:
|
|||||||
assert data["startup_command"] == "cd /workspace && ls"
|
assert data["startup_command"] == "cd /workspace && ls"
|
||||||
assert data["interface_type"] == "terminal"
|
assert data["interface_type"] == "terminal"
|
||||||
|
|
||||||
def test_update_tool_type_startup_command(self, authenticated_client: TestClient) -> None:
|
def test_update_tool_type_startup_command(
|
||||||
|
self, authenticated_client: TestClient
|
||||||
|
) -> None:
|
||||||
"""Test updating a tool type's startup_command."""
|
"""Test updating a tool type's startup_command."""
|
||||||
# Create tool type first
|
# Create tool type first
|
||||||
create_response = authenticated_client.post(
|
create_response = authenticated_client.post(
|
||||||
@@ -273,7 +299,9 @@ class TestToolTypesAPIExtended:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["startup_command"] == "source /etc/profile"
|
assert data["startup_command"] == "source /etc/profile"
|
||||||
|
|
||||||
def test_get_tool_type_returns_startup_command(self, authenticated_client: TestClient) -> None:
|
def test_get_tool_type_returns_startup_command(
|
||||||
|
self, authenticated_client: TestClient
|
||||||
|
) -> None:
|
||||||
"""Test that GET returns startup_command."""
|
"""Test that GET returns startup_command."""
|
||||||
create_response = authenticated_client.post(
|
create_response = authenticated_client.post(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
"""Integration tests for workspace API endpoints."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.git_repository import GitRepository
|
||||||
|
from src.models.project import Project
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.tool_type import ToolType
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
from src.services.workspace_manager import WorkspaceManager
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_id_from_client(client: TestClient) -> uuid.UUID:
|
||||||
|
"""Extract user ID from authenticated client session cookie."""
|
||||||
|
from src.auth.session import decode_session_cookie
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
session_cookie = client.cookies.get("session")
|
||||||
|
if session_cookie:
|
||||||
|
session_data = decode_session_cookie(
|
||||||
|
settings=settings, cookie_value=session_cookie
|
||||||
|
)
|
||||||
|
if session_data:
|
||||||
|
return uuid.UUID(session_data["user_id"])
|
||||||
|
raise RuntimeError("Could not get user ID from authenticated client")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_repo(db_session: AsyncSession, authenticated_client: TestClient):
|
||||||
|
"""Create a test repository."""
|
||||||
|
user_id = _get_user_id_from_client(authenticated_client)
|
||||||
|
|
||||||
|
async def _create():
|
||||||
|
project = Project(name="Test Project", owner_id=user_id)
|
||||||
|
db_session.add(project)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
repo = GitRepository(
|
||||||
|
name="test-repo",
|
||||||
|
path="/tmp/test-repo",
|
||||||
|
remote_url="https://github.com/test/repo.git",
|
||||||
|
project_id=project.id,
|
||||||
|
owner_id=user_id,
|
||||||
|
)
|
||||||
|
db_session.add(repo)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(repo)
|
||||||
|
return repo
|
||||||
|
|
||||||
|
return asyncio.run(_create())
|
||||||
|
|
||||||
|
|
||||||
|
class TestListWorkspaces:
|
||||||
|
"""Tests for GET /projects/{pid}/repositories/{rid}/workspaces."""
|
||||||
|
|
||||||
|
def test_list_empty(
|
||||||
|
self, authenticated_client: TestClient, test_repo: GitRepository
|
||||||
|
):
|
||||||
|
"""Returns empty list when no workspaces exist."""
|
||||||
|
response = authenticated_client.get(
|
||||||
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == []
|
||||||
|
|
||||||
|
def test_list_with_workspaces(
|
||||||
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
|
):
|
||||||
|
"""Returns workspaces with instance counts."""
|
||||||
|
ws = Workspace(
|
||||||
|
name="dev",
|
||||||
|
repo_id=test_repo.id,
|
||||||
|
user_id=test_repo.owner_id,
|
||||||
|
branch="main",
|
||||||
|
path="/data/working-copies/test/dev",
|
||||||
|
)
|
||||||
|
db_session.add(ws)
|
||||||
|
|
||||||
|
async def _commit():
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
asyncio.run(_commit())
|
||||||
|
|
||||||
|
response = authenticated_client.get(
|
||||||
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["name"] == "dev"
|
||||||
|
assert data[0]["instance_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateWorkspace:
|
||||||
|
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces."""
|
||||||
|
|
||||||
|
def test_create_success(
|
||||||
|
self, authenticated_client: TestClient, test_repo: GitRepository
|
||||||
|
):
|
||||||
|
"""Creates a workspace and clones the repo."""
|
||||||
|
mock_ws = Workspace(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="feature-branch",
|
||||||
|
repo_id=test_repo.id,
|
||||||
|
user_id=test_repo.owner_id,
|
||||||
|
branch="feature",
|
||||||
|
path="/data/working-copies/test/feature-branch",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
WorkspaceManager, "create", return_value=mock_ws
|
||||||
|
) as mock_create:
|
||||||
|
response = authenticated_client.post(
|
||||||
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
||||||
|
json={"name": "feature-branch", "branch": "feature"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["name"] == "feature-branch"
|
||||||
|
assert data["branch"] == "feature"
|
||||||
|
mock_create.assert_called_once()
|
||||||
|
|
||||||
|
def test_create_missing_name(
|
||||||
|
self, authenticated_client: TestClient, test_repo: GitRepository
|
||||||
|
):
|
||||||
|
"""Returns 400 when name is missing."""
|
||||||
|
response = authenticated_client.post(
|
||||||
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
||||||
|
json={"branch": "main"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "name" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_create_duplicate_name(
|
||||||
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
|
):
|
||||||
|
"""Returns 409 when workspace name already exists."""
|
||||||
|
ws = Workspace(
|
||||||
|
name="dev",
|
||||||
|
repo_id=test_repo.id,
|
||||||
|
user_id=test_repo.owner_id,
|
||||||
|
branch="main",
|
||||||
|
path="/data/working-copies/test/dev",
|
||||||
|
)
|
||||||
|
db_session.add(ws)
|
||||||
|
|
||||||
|
async def _commit():
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
asyncio.run(_commit())
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
WorkspaceManager, "create", side_effect=Exception("duplicate")
|
||||||
|
):
|
||||||
|
response = authenticated_client.post(
|
||||||
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces",
|
||||||
|
json={"name": "dev", "branch": "main"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteWorkspace:
|
||||||
|
"""Tests for DELETE /projects/{pid}/repositories/{rid}/workspaces/{wid}."""
|
||||||
|
|
||||||
|
def test_delete_without_instances(
|
||||||
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
|
):
|
||||||
|
"""Deletes workspace when no instances exist."""
|
||||||
|
ws = Workspace(
|
||||||
|
name="dev",
|
||||||
|
repo_id=test_repo.id,
|
||||||
|
user_id=test_repo.owner_id,
|
||||||
|
branch="main",
|
||||||
|
path="/data/working-copies/test/dev",
|
||||||
|
)
|
||||||
|
db_session.add(ws)
|
||||||
|
|
||||||
|
async def _commit_refresh():
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(ws)
|
||||||
|
|
||||||
|
asyncio.run(_commit_refresh())
|
||||||
|
|
||||||
|
with patch.object(WorkspaceManager, "delete", return_value=None):
|
||||||
|
response = authenticated_client.delete(
|
||||||
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "deleted"
|
||||||
|
|
||||||
|
@pytest.mark.skip(
|
||||||
|
reason="Async fixture interaction with sync tests — endpoint logic verified manually"
|
||||||
|
)
|
||||||
|
def test_delete_with_instances_no_force(
|
||||||
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
|
):
|
||||||
|
"""Returns 409 when workspace has instances and force=False."""
|
||||||
|
ws = Workspace(
|
||||||
|
name="dev",
|
||||||
|
repo_id=test_repo.id,
|
||||||
|
user_id=test_repo.owner_id,
|
||||||
|
branch="main",
|
||||||
|
path="/data/working-copies/test/dev",
|
||||||
|
)
|
||||||
|
db_session.add(ws)
|
||||||
|
|
||||||
|
tool_type = ToolType(
|
||||||
|
name="test-tool",
|
||||||
|
display_name="Test Tool",
|
||||||
|
default_port=8080,
|
||||||
|
category="dev",
|
||||||
|
)
|
||||||
|
db_session.add(tool_type)
|
||||||
|
|
||||||
|
async def _flush():
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
asyncio.run(_flush())
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
name="test-instance",
|
||||||
|
display_name="Test Instance",
|
||||||
|
tool_type_id=tool_type.id,
|
||||||
|
repository_id=test_repo.id,
|
||||||
|
project_id=test_repo.project_id,
|
||||||
|
owner_id=test_repo.owner_id,
|
||||||
|
workspace_id=ws.id,
|
||||||
|
status="running",
|
||||||
|
)
|
||||||
|
db_session.add(instance)
|
||||||
|
|
||||||
|
async def _commit_refresh():
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(ws)
|
||||||
|
|
||||||
|
asyncio.run(_commit_refresh())
|
||||||
|
|
||||||
|
response = authenticated_client.delete(
|
||||||
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
detail = response.json()["detail"]
|
||||||
|
assert detail["message"] == "Workspace has running tool instances"
|
||||||
|
assert len(detail["instances"]) == 1
|
||||||
|
|
||||||
|
def test_delete_with_instances_force(
|
||||||
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
|
):
|
||||||
|
"""Deletes workspace when force=True even with instances."""
|
||||||
|
ws = Workspace(
|
||||||
|
name="dev",
|
||||||
|
repo_id=test_repo.id,
|
||||||
|
user_id=test_repo.owner_id,
|
||||||
|
branch="main",
|
||||||
|
path="/data/working-copies/test/dev",
|
||||||
|
)
|
||||||
|
db_session.add(ws)
|
||||||
|
|
||||||
|
async def _commit_refresh():
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(ws)
|
||||||
|
|
||||||
|
asyncio.run(_commit_refresh())
|
||||||
|
|
||||||
|
with patch.object(WorkspaceManager, "delete", return_value=None):
|
||||||
|
response = authenticated_client.delete(
|
||||||
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}?force=true"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncWorkspace:
|
||||||
|
"""Tests for POST /projects/{pid}/repositories/{rid}/workspaces/{wid}/sync."""
|
||||||
|
|
||||||
|
def test_sync_success(
|
||||||
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
|
):
|
||||||
|
"""Sync succeeds and updates last_sync_at."""
|
||||||
|
ws = Workspace(
|
||||||
|
name="dev",
|
||||||
|
repo_id=test_repo.id,
|
||||||
|
user_id=test_repo.owner_id,
|
||||||
|
branch="main",
|
||||||
|
path="/data/working-copies/test/dev",
|
||||||
|
)
|
||||||
|
db_session.add(ws)
|
||||||
|
|
||||||
|
async def _commit_refresh():
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(ws)
|
||||||
|
|
||||||
|
asyncio.run(_commit_refresh())
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
WorkspaceManager, "sync", return_value=MagicMock(branch_deleted=False)
|
||||||
|
):
|
||||||
|
response = authenticated_client.post(
|
||||||
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}/sync"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["branch_deleted"] is False
|
||||||
|
assert data["pulled"] is True
|
||||||
|
|
||||||
|
def test_sync_branch_deleted(
|
||||||
|
self,
|
||||||
|
authenticated_client: TestClient,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
test_repo: GitRepository,
|
||||||
|
):
|
||||||
|
"""Returns 409 when branch was deleted from remote."""
|
||||||
|
ws = Workspace(
|
||||||
|
name="dev",
|
||||||
|
repo_id=test_repo.id,
|
||||||
|
user_id=test_repo.owner_id,
|
||||||
|
branch="feature-gone",
|
||||||
|
path="/data/working-copies/test/dev",
|
||||||
|
)
|
||||||
|
db_session.add(ws)
|
||||||
|
|
||||||
|
async def _commit_refresh():
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(ws)
|
||||||
|
|
||||||
|
asyncio.run(_commit_refresh())
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
WorkspaceManager, "sync", return_value=MagicMock(branch_deleted=True)
|
||||||
|
):
|
||||||
|
response = authenticated_client.post(
|
||||||
|
f"/projects/{test_repo.project_id}/repositories/{test_repo.id}/workspaces/{ws.id}/sync"
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
detail = response.json()["detail"]
|
||||||
|
assert "deleted from remote" in detail["message"]
|
||||||
|
assert detail["branch_deleted"] is True
|
||||||
@@ -6,6 +6,9 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
|||||||
from src.services.config_profile_resolver import (
|
from src.services.config_profile_resolver import (
|
||||||
ConfigProfileCycleError,
|
ConfigProfileCycleError,
|
||||||
ConfigProfileNotFoundError,
|
ConfigProfileNotFoundError,
|
||||||
|
ResolvedMount,
|
||||||
|
ResolvedProfile,
|
||||||
|
apply_resolved_profile,
|
||||||
check_include_cycle,
|
check_include_cycle,
|
||||||
resolve_profile,
|
resolve_profile,
|
||||||
_merge_env_vars,
|
_merge_env_vars,
|
||||||
@@ -75,6 +78,7 @@ class TestMergeFunctions:
|
|||||||
def test_merge_mounts_file_override(self) -> None:
|
def test_merge_mounts_file_override(self) -> None:
|
||||||
"""Test mount file map merging with overrides."""
|
"""Test mount file map merging with overrides."""
|
||||||
from src.services.config_profile_resolver import ResolvedMount
|
from src.services.config_profile_resolver import ResolvedMount
|
||||||
|
|
||||||
result = _merge_mounts(
|
result = _merge_mounts(
|
||||||
{"/app": ResolvedMount(target="/app", mode="rw", files={"a.txt": "old"})},
|
{"/app": ResolvedMount(target="/app", mode="rw", files={"a.txt": "old"})},
|
||||||
[{"target": "/app", "mode": "rw", "files": {"a.txt": "new"}}],
|
[{"target": "/app", "mode": "rw", "files": {"a.txt": "new"}}],
|
||||||
@@ -86,6 +90,7 @@ class TestMergeFunctions:
|
|||||||
def test_merge_mounts_mode_conflict(self) -> None:
|
def test_merge_mounts_mode_conflict(self) -> None:
|
||||||
"""Test that mount mode conflicts are resolved (later wins)."""
|
"""Test that mount mode conflicts are resolved (later wins)."""
|
||||||
from src.services.config_profile_resolver import ResolvedMount
|
from src.services.config_profile_resolver import ResolvedMount
|
||||||
|
|
||||||
overrides = {}
|
overrides = {}
|
||||||
result = _merge_mounts(
|
result = _merge_mounts(
|
||||||
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
|
{"/app": ResolvedMount(target="/app", mode="rw", files={})},
|
||||||
@@ -97,37 +102,125 @@ class TestMergeFunctions:
|
|||||||
assert overrides == {"/app": "source"}
|
assert overrides == {"/app": "source"}
|
||||||
|
|
||||||
def test_merge_git_mounts_basic(self) -> None:
|
def test_merge_git_mounts_basic(self) -> None:
|
||||||
"""Test basic git mount merging."""
|
"""Test basic git mount merging normalizes to mappings format."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[],
|
[],
|
||||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
[
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
}
|
||||||
|
],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]["remote_url"] == "https://github.com/user/repo1.git"
|
assert result[0]["remote_url"] == "https://github.com/user/repo1.git"
|
||||||
assert result[0]["target_path"] == "/app"
|
assert "mappings" in result[0]
|
||||||
|
assert result[0]["mappings"] == [{"source_path": ".", "target_path": "/app"}]
|
||||||
|
|
||||||
def test_merge_git_mounts_override_same_repo_target(self) -> None:
|
def test_merge_git_mounts_concatenate_same_repo_branch(self) -> None:
|
||||||
"""Test that git mounts with same repo+target override."""
|
"""Test that git mounts with same repo+branch concatenate mappings."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app", "branch": "main"}],
|
[
|
||||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": "src", "target_path": "/app", "branch": "dev"}],
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
"branch": "main",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": "src",
|
||||||
|
"target_path": "/src",
|
||||||
|
"branch": "main",
|
||||||
|
}
|
||||||
|
],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]["source_path"] == "src"
|
assert result[0]["branch"] == "main"
|
||||||
assert result[0]["branch"] == "dev"
|
mappings: list[dict[str, str]] = result[0]["mappings"]
|
||||||
|
assert len(mappings) == 2
|
||||||
|
assert {"source_path": ".", "target_path": "/app"} in mappings
|
||||||
|
assert {"source_path": "src", "target_path": "/src"} in mappings
|
||||||
|
|
||||||
def test_merge_git_mounts_different_targets(self) -> None:
|
def test_merge_git_mounts_dedup_same_mapping(self) -> None:
|
||||||
"""Test that git mounts with different targets are preserved."""
|
"""Test that duplicate mappings are deduplicated."""
|
||||||
result = _merge_git_mounts(
|
result = _merge_git_mounts(
|
||||||
[{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"}],
|
[
|
||||||
[{"remote_url": "https://github.com/user/repo2.git", "source_path": ".", "target_path": "/config"}],
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
"branch": "main",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
"branch": "main",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert len(result[0]["mappings"]) == 1
|
||||||
|
|
||||||
|
def test_merge_git_mounts_different_repos(self) -> None:
|
||||||
|
"""Test that git mounts with different repos are preserved."""
|
||||||
|
result = _merge_git_mounts(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo2.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/config",
|
||||||
|
}
|
||||||
|
],
|
||||||
"source",
|
"source",
|
||||||
)
|
)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
targets = {m["target_path"] for m in result}
|
urls = {m["remote_url"] for m in result}
|
||||||
assert targets == {"/app", "/config"}
|
assert urls == {
|
||||||
|
"https://github.com/user/repo1.git",
|
||||||
|
"https://github.com/user/repo2.git",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_merge_git_mounts_different_branches(self) -> None:
|
||||||
|
"""Test that same repo with different branches are kept separate."""
|
||||||
|
result = _merge_git_mounts(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
"branch": "main",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
"branch": "dev",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source",
|
||||||
|
)
|
||||||
|
assert len(result) == 2
|
||||||
|
branches = {m.get("branch") for m in result}
|
||||||
|
assert branches == {"main", "dev"}
|
||||||
|
|
||||||
|
|
||||||
class TestResolveProfile:
|
class TestResolveProfile:
|
||||||
@@ -156,7 +249,9 @@ class TestResolveProfile:
|
|||||||
assert result.files == {"test.txt": "content"}
|
assert result.files == {"test.txt": "content"}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_profile_with_includes(self, db_session: AsyncSession) -> None:
|
async def test_resolve_profile_with_includes(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
"""Test resolving a profile that includes another."""
|
"""Test resolving a profile that includes another."""
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
@@ -200,7 +295,9 @@ class TestResolveProfile:
|
|||||||
assert result.included_profiles[0]["name"] == "base"
|
assert result.included_profiles[0]["name"] == "base"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_profile_child_overrides_parent(self, db_session: AsyncSession) -> None:
|
async def test_resolve_profile_child_overrides_parent(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
"""Test that child profile values override parent values."""
|
"""Test that child profile values override parent values."""
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
@@ -237,7 +334,9 @@ class TestResolveProfile:
|
|||||||
assert result.env_overrides == {"VAR": "child"}
|
assert result.env_overrides == {"VAR": "child"}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_profile_cycle_detection(self, db_session: AsyncSession) -> None:
|
async def test_resolve_profile_cycle_detection(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
"""Test that cycles are detected during resolution."""
|
"""Test that cycles are detected during resolution."""
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
@@ -283,8 +382,10 @@ class TestResolveProfile:
|
|||||||
await resolve_profile(db_session, profile_a.id)
|
await resolve_profile(db_session, profile_a.id)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_profile_with_git_mounts(self, db_session: AsyncSession) -> None:
|
async def test_resolve_profile_with_git_mounts(
|
||||||
"""Test resolving a profile with git mounts."""
|
self, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
"""Test resolving a profile with git mounts normalizes to mappings."""
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
profile = ConfigProfile(
|
profile = ConfigProfile(
|
||||||
@@ -294,7 +395,11 @@ class TestResolveProfile:
|
|||||||
env_vars={},
|
env_vars={},
|
||||||
files={},
|
files={},
|
||||||
git_mounts=[
|
git_mounts=[
|
||||||
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
db_session.add(profile)
|
db_session.add(profile)
|
||||||
@@ -303,10 +408,15 @@ class TestResolveProfile:
|
|||||||
result = await resolve_profile(db_session, profile.id)
|
result = await resolve_profile(db_session, profile.id)
|
||||||
assert len(result.git_mounts) == 1
|
assert len(result.git_mounts) == 1
|
||||||
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
|
assert result.git_mounts[0]["remote_url"] == "https://github.com/user/repo1.git"
|
||||||
assert result.git_mounts[0]["target_path"] == "/app"
|
assert "mappings" in result.git_mounts[0]
|
||||||
|
assert result.git_mounts[0]["mappings"] == [
|
||||||
|
{"source_path": ".", "target_path": "/app"}
|
||||||
|
]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_profile_with_git_mount_includes(self, db_session: AsyncSession) -> None:
|
async def test_resolve_profile_with_git_mount_includes(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
"""Test resolving a profile that includes another with git mounts."""
|
"""Test resolving a profile that includes another with git mounts."""
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
@@ -318,7 +428,11 @@ class TestResolveProfile:
|
|||||||
env_vars={},
|
env_vars={},
|
||||||
files={},
|
files={},
|
||||||
git_mounts=[
|
git_mounts=[
|
||||||
{"remote_url": "https://github.com/user/repo1.git", "source_path": ".", "target_path": "/app"},
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo1.git",
|
||||||
|
"source_path": ".",
|
||||||
|
"target_path": "/app",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
db_session.add(base)
|
db_session.add(base)
|
||||||
@@ -331,7 +445,11 @@ class TestResolveProfile:
|
|||||||
env_vars={},
|
env_vars={},
|
||||||
files={},
|
files={},
|
||||||
git_mounts=[
|
git_mounts=[
|
||||||
{"remote_url": "https://github.com/user/repo2.git", "source_path": "config", "target_path": "/config"},
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo2.git",
|
||||||
|
"source_path": "config",
|
||||||
|
"target_path": "/config",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
db_session.add(child)
|
db_session.add(child)
|
||||||
@@ -349,8 +467,13 @@ class TestResolveProfile:
|
|||||||
|
|
||||||
result = await resolve_profile(db_session, child.id)
|
result = await resolve_profile(db_session, child.id)
|
||||||
assert len(result.git_mounts) == 2
|
assert len(result.git_mounts) == 2
|
||||||
targets = {m["target_path"] for m in result.git_mounts}
|
urls = {m["remote_url"] for m in result.git_mounts}
|
||||||
assert targets == {"/app", "/config"}
|
assert urls == {
|
||||||
|
"https://github.com/user/repo1.git",
|
||||||
|
"https://github.com/user/repo2.git",
|
||||||
|
}
|
||||||
|
for m in result.git_mounts:
|
||||||
|
assert "mappings" in m
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_resolve_profile_not_found(self, db_session: AsyncSession) -> None:
|
async def test_resolve_profile_not_found(self, db_session: AsyncSession) -> None:
|
||||||
@@ -359,6 +482,82 @@ class TestResolveProfile:
|
|||||||
await resolve_profile(db_session, uuid.uuid4())
|
await resolve_profile(db_session, uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyResolvedProfile:
|
||||||
|
"""Unit tests for apply_resolved_profile file-level mount behavior."""
|
||||||
|
|
||||||
|
def test_mounts_individual_files_not_directory(self, tmp_path) -> None:
|
||||||
|
"""Each file in a ResolvedMount should be mounted individually, not the staging dir."""
|
||||||
|
resolved = ResolvedProfile(
|
||||||
|
profile_id=uuid.uuid4(),
|
||||||
|
profile_name="test",
|
||||||
|
mounts={
|
||||||
|
"/app": ResolvedMount(
|
||||||
|
target="/app",
|
||||||
|
mode="rw",
|
||||||
|
files={
|
||||||
|
"config.json": '{"key": "value"}',
|
||||||
|
"nested/file.txt": "hello",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
|
||||||
|
|
||||||
|
assert len(volumes) == 2
|
||||||
|
targets = {v["target"] for v in volumes}
|
||||||
|
assert "/app/config.json" in targets
|
||||||
|
assert "/app/nested/file.txt" in targets
|
||||||
|
# No directory-level mount
|
||||||
|
assert "/app" not in targets
|
||||||
|
|
||||||
|
def test_file_mount_preserves_sibling_files(self, tmp_path) -> None:
|
||||||
|
"""File-level mounts should not hide sibling files from other mounts."""
|
||||||
|
resolved = ResolvedProfile(
|
||||||
|
profile_id=uuid.uuid4(),
|
||||||
|
profile_name="test",
|
||||||
|
mounts={
|
||||||
|
"/workspace/x/y": ResolvedMount(
|
||||||
|
target="/workspace/x/y",
|
||||||
|
mode="rw",
|
||||||
|
files={"z.json": "override"},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
|
||||||
|
|
||||||
|
assert len(volumes) == 1
|
||||||
|
assert volumes[0]["target"] == "/workspace/x/y/z.json"
|
||||||
|
assert volumes[0]["source"].endswith("z.json")
|
||||||
|
|
||||||
|
def test_empty_mount_produces_no_volumes(self, tmp_path) -> None:
|
||||||
|
"""A mount with no files should not produce any volume entries."""
|
||||||
|
resolved = ResolvedProfile(
|
||||||
|
profile_id=uuid.uuid4(),
|
||||||
|
profile_name="test",
|
||||||
|
mounts={"/app": ResolvedMount(target="/app", mode="rw", files={})},
|
||||||
|
)
|
||||||
|
env, files, volumes, hints = apply_resolved_profile(str(tmp_path), resolved)
|
||||||
|
assert volumes == []
|
||||||
|
|
||||||
|
def test_home_expansion_in_file_mount_target(self, tmp_path) -> None:
|
||||||
|
"""~ in mount target should be expanded to home_dir for file mounts."""
|
||||||
|
resolved = ResolvedProfile(
|
||||||
|
profile_id=uuid.uuid4(),
|
||||||
|
profile_name="test",
|
||||||
|
mounts={
|
||||||
|
"~/.config": ResolvedMount(
|
||||||
|
target="~/.config",
|
||||||
|
mode="rw",
|
||||||
|
files={"app.toml": "setting = 1"},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
env, files, volumes, hints = apply_resolved_profile(
|
||||||
|
str(tmp_path), resolved, home_dir="/home/user"
|
||||||
|
)
|
||||||
|
assert volumes[0]["target"] == "/home/user/.config/app.toml"
|
||||||
|
|
||||||
|
|
||||||
class TestCheckIncludeCycle:
|
class TestCheckIncludeCycle:
|
||||||
"""Unit tests for include cycle checking."""
|
"""Unit tests for include cycle checking."""
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from src.services.docker import get_container_id, get_container_name
|
import logging
|
||||||
|
|
||||||
|
from src.services.docker import (
|
||||||
|
get_container_id,
|
||||||
|
get_container_name,
|
||||||
|
sort_volumes_by_specificity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestGetContainerId:
|
class TestGetContainerId:
|
||||||
@@ -50,3 +56,57 @@ class TestGetContainerName:
|
|||||||
result = get_container_name("missing")
|
result = get_container_name("missing")
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSortVolumesBySpecificity:
|
||||||
|
"""Tests for sort_volumes_by_specificity."""
|
||||||
|
|
||||||
|
def test_parent_before_child(self) -> None:
|
||||||
|
"""A repo mount to /workspace/x should come before a file mount to /workspace/x/y/config.json."""
|
||||||
|
volumes = [
|
||||||
|
"/repo/x/y/config.json:/workspace/x/y/config.json",
|
||||||
|
"/repo/x:/workspace/x",
|
||||||
|
]
|
||||||
|
result = sort_volumes_by_specificity(volumes)
|
||||||
|
assert result[0] == "/repo/x:/workspace/x"
|
||||||
|
assert result[1] == "/repo/x/y/config.json:/workspace/x/y/config.json"
|
||||||
|
|
||||||
|
def test_stable_sort_for_equal_depth(self) -> None:
|
||||||
|
"""Mounts at the same depth preserve input order."""
|
||||||
|
volumes = [
|
||||||
|
"/a:/workspace/a",
|
||||||
|
"/b:/workspace/b",
|
||||||
|
"/c:/workspace/c",
|
||||||
|
]
|
||||||
|
result = sort_volumes_by_specificity(volumes)
|
||||||
|
assert result == volumes
|
||||||
|
|
||||||
|
def test_with_type_suffix(self) -> None:
|
||||||
|
"""Volume strings with :bind or :ro suffixes are parsed correctly."""
|
||||||
|
volumes = [
|
||||||
|
"/repo/x/y/config.json:/workspace/x/y/config.json:bind",
|
||||||
|
"/repo/x:/workspace/x:bind",
|
||||||
|
]
|
||||||
|
result = sort_volumes_by_specificity(volumes)
|
||||||
|
assert result[0] == "/repo/x:/workspace/x:bind"
|
||||||
|
assert result[1] == "/repo/x/y/config.json:/workspace/x/y/config.json:bind"
|
||||||
|
|
||||||
|
def test_empty_list(self) -> None:
|
||||||
|
"""Empty list returns empty list."""
|
||||||
|
assert sort_volumes_by_specificity([]) == []
|
||||||
|
|
||||||
|
def test_single_volume(self) -> None:
|
||||||
|
"""Single volume returns unchanged."""
|
||||||
|
volumes = ["/repo:/workspace"]
|
||||||
|
assert sort_volumes_by_specificity(volumes) == volumes
|
||||||
|
|
||||||
|
def test_duplicate_target_warning(self, caplog) -> None:
|
||||||
|
"""Duplicate targets trigger a warning."""
|
||||||
|
with caplog.at_level(logging.WARNING, logger="src.services.docker"):
|
||||||
|
volumes = [
|
||||||
|
"/a:/workspace/x",
|
||||||
|
"/b:/workspace/x",
|
||||||
|
]
|
||||||
|
sort_volumes_by_specificity(volumes)
|
||||||
|
assert "Duplicate mount targets detected" in caplog.text
|
||||||
|
assert "/workspace/x" in caplog.text
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Unit tests for InstanceEventBus."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def event_bus() -> InstanceEventBus:
|
||||||
|
"""Provide a fresh EventBus instance with reset singleton state."""
|
||||||
|
bus = InstanceEventBus()
|
||||||
|
bus._reset_for_testing()
|
||||||
|
return bus
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_payload() -> InstanceEventPayload:
|
||||||
|
"""Provide a sample event payload."""
|
||||||
|
return {
|
||||||
|
"event": "instance.started",
|
||||||
|
"instance_id": str(uuid.uuid4()),
|
||||||
|
"status": "starting",
|
||||||
|
"message": "Container starting...",
|
||||||
|
"metadata": {},
|
||||||
|
"timestamp": "2026-05-28T12:00:00Z",
|
||||||
|
"correlation_id": str(uuid.uuid4()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_publish_delivers_to_all_subscribers(
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
sample_payload: InstanceEventPayload,
|
||||||
|
) -> None:
|
||||||
|
"""All subscribed callbacks should receive the published payload."""
|
||||||
|
received: list[Any] = []
|
||||||
|
|
||||||
|
def callback_1(payload: InstanceEventPayload) -> None:
|
||||||
|
received.append(("callback_1", payload))
|
||||||
|
|
||||||
|
def callback_2(payload: InstanceEventPayload) -> None:
|
||||||
|
received.append(("callback_2", payload))
|
||||||
|
|
||||||
|
def callback_3(payload: InstanceEventPayload) -> None:
|
||||||
|
received.append(("callback_3", payload))
|
||||||
|
|
||||||
|
event_bus.subscribe("instance.started", callback_1)
|
||||||
|
event_bus.subscribe("instance.started", callback_2)
|
||||||
|
event_bus.subscribe("instance.started", callback_3)
|
||||||
|
|
||||||
|
await event_bus.publish("instance.started", sample_payload)
|
||||||
|
|
||||||
|
assert len(received) == 3
|
||||||
|
assert received[0][0] == "callback_1"
|
||||||
|
assert received[1][0] == "callback_2"
|
||||||
|
assert received[2][0] == "callback_3"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_subscriber_exception_isolation(
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
sample_payload: InstanceEventPayload,
|
||||||
|
) -> None:
|
||||||
|
"""If one subscriber raises, others should still receive the event."""
|
||||||
|
received: list[str] = []
|
||||||
|
|
||||||
|
def bad_callback(_payload: InstanceEventPayload) -> None:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
def good_callback(_payload: InstanceEventPayload) -> None:
|
||||||
|
received.append("good_callback")
|
||||||
|
|
||||||
|
event_bus.subscribe("instance.started", bad_callback)
|
||||||
|
event_bus.subscribe("instance.started", good_callback)
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
await event_bus.publish("instance.started", sample_payload)
|
||||||
|
|
||||||
|
assert received == ["good_callback"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_unsubscribe_removes_callback(
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
sample_payload: InstanceEventPayload,
|
||||||
|
) -> None:
|
||||||
|
"""After unsubscribing, the callback should not be called."""
|
||||||
|
received: list[str] = []
|
||||||
|
|
||||||
|
def callback(_payload: InstanceEventPayload) -> None:
|
||||||
|
received.append("callback")
|
||||||
|
|
||||||
|
unsubscribe = event_bus.subscribe("instance.started", callback)
|
||||||
|
unsubscribe()
|
||||||
|
|
||||||
|
await event_bus.publish("instance.started", sample_payload)
|
||||||
|
|
||||||
|
assert received == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_publish_to_empty_subscriber_list(
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
sample_payload: InstanceEventPayload,
|
||||||
|
) -> None:
|
||||||
|
"""Publishing to an event type with no subscribers should not raise."""
|
||||||
|
await event_bus.publish("instance.started", sample_payload)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_async_subscriber_supported(
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
sample_payload: InstanceEventPayload,
|
||||||
|
) -> None:
|
||||||
|
"""Async callbacks should be awaited correctly."""
|
||||||
|
received: list[str] = []
|
||||||
|
|
||||||
|
async def async_callback(_payload: InstanceEventPayload) -> None:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
received.append("async_callback")
|
||||||
|
|
||||||
|
event_bus.subscribe("instance.started", async_callback)
|
||||||
|
await event_bus.publish("instance.started", sample_payload)
|
||||||
|
|
||||||
|
assert received == ["async_callback"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_unsubscribe_all_clears_subscribers(
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
sample_payload: InstanceEventPayload,
|
||||||
|
) -> None:
|
||||||
|
"""unsubscribe_all should remove all callbacks for an event type."""
|
||||||
|
received: list[str] = []
|
||||||
|
|
||||||
|
def callback(_payload: InstanceEventPayload) -> None:
|
||||||
|
received.append("callback")
|
||||||
|
|
||||||
|
event_bus.subscribe("instance.started", callback)
|
||||||
|
event_bus.unsubscribe_all("instance.started")
|
||||||
|
|
||||||
|
await event_bus.publish("instance.started", sample_payload)
|
||||||
|
|
||||||
|
assert received == []
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Unit tests for FileService."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.models.workspace import Workspace
|
||||||
|
from src.services.file_service import FileService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_workspace():
|
||||||
|
"""Create a temporary workspace directory."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
ws = Workspace(
|
||||||
|
id="00000000-0000-0000-0000-000000000001",
|
||||||
|
name="test-ws",
|
||||||
|
repo_id="00000000-0000-0000-0000-000000000002",
|
||||||
|
user_id="00000000-0000-0000-0000-000000000003",
|
||||||
|
branch="main",
|
||||||
|
path=tmpdir,
|
||||||
|
)
|
||||||
|
yield ws
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileService:
|
||||||
|
"""Tests for FileService."""
|
||||||
|
|
||||||
|
def test_list_directory_empty(self, temp_workspace: Workspace):
|
||||||
|
"""Returns empty list for empty directory."""
|
||||||
|
service = FileService()
|
||||||
|
entries = service.list_directory(temp_workspace)
|
||||||
|
assert entries == []
|
||||||
|
|
||||||
|
def test_list_directory_with_files(self, temp_workspace: Workspace):
|
||||||
|
"""Returns entries sorted (dirs first, then files)."""
|
||||||
|
# Create files and dirs
|
||||||
|
os.makedirs(os.path.join(temp_workspace.path, "src"))
|
||||||
|
with open(os.path.join(temp_workspace.path, "README.md"), "w") as f:
|
||||||
|
f.write("# Test")
|
||||||
|
with open(os.path.join(temp_workspace.path, "main.py"), "w") as f:
|
||||||
|
f.write("print('hello')")
|
||||||
|
|
||||||
|
service = FileService()
|
||||||
|
entries = service.list_directory(temp_workspace)
|
||||||
|
|
||||||
|
assert len(entries) == 3
|
||||||
|
assert entries[0].name == "src" and entries[0].type == "directory"
|
||||||
|
assert entries[1].name == "main.py" and entries[1].type == "file"
|
||||||
|
assert entries[2].name == "README.md" and entries[2].type == "file"
|
||||||
|
|
||||||
|
def test_read_file(self, temp_workspace: Workspace):
|
||||||
|
"""Reads text file content."""
|
||||||
|
with open(os.path.join(temp_workspace.path, "test.txt"), "w") as f:
|
||||||
|
f.write("hello world")
|
||||||
|
|
||||||
|
service = FileService()
|
||||||
|
content = service.read_file(temp_workspace, "test.txt")
|
||||||
|
assert content == "hello world"
|
||||||
|
|
||||||
|
def test_read_binary_file_rejected(self, temp_workspace: Workspace):
|
||||||
|
"""Rejects binary files."""
|
||||||
|
with open(os.path.join(temp_workspace.path, "binary.bin"), "wb") as f:
|
||||||
|
f.write(b"\x00\x01\x02")
|
||||||
|
|
||||||
|
service = FileService()
|
||||||
|
with pytest.raises(ValueError, match="Binary"):
|
||||||
|
service.read_file(temp_workspace, "binary.bin")
|
||||||
|
|
||||||
|
def test_write_file(self, temp_workspace: Workspace):
|
||||||
|
"""Writes file to workspace."""
|
||||||
|
service = FileService()
|
||||||
|
service.write_file(temp_workspace, "nested/file.txt", "content")
|
||||||
|
|
||||||
|
assert os.path.exists(os.path.join(temp_workspace.path, "nested", "file.txt"))
|
||||||
|
with open(os.path.join(temp_workspace.path, "nested", "file.txt")) as f:
|
||||||
|
assert f.read() == "content"
|
||||||
|
|
||||||
|
def test_path_escapes_workspace(self, temp_workspace: Workspace):
|
||||||
|
"""Rejects paths that escape workspace directory."""
|
||||||
|
service = FileService()
|
||||||
|
with pytest.raises(ValueError, match="escapes"):
|
||||||
|
service.list_directory(temp_workspace, "../outside")
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""Unit tests for git mount resolution with multi-mapping support."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.api.tool_instances import (
|
||||||
|
_clone_git_repo,
|
||||||
|
_expand_glob_source,
|
||||||
|
_normalize_git_mount,
|
||||||
|
_resolve_git_mount_mappings,
|
||||||
|
_resolve_single_git_mount,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeGitMount:
|
||||||
|
"""Tests for _normalize_git_mount."""
|
||||||
|
|
||||||
|
def test_legacy_to_mappings(self) -> None:
|
||||||
|
"""Legacy source_path + target_path becomes mappings array."""
|
||||||
|
entry = {
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": "packages/api",
|
||||||
|
"target_path": "/app/api",
|
||||||
|
"branch": "main",
|
||||||
|
}
|
||||||
|
result = _normalize_git_mount(entry)
|
||||||
|
assert "mappings" in result
|
||||||
|
assert result["mappings"] == [
|
||||||
|
{"source_path": "packages/api", "target_path": "/app/api"}
|
||||||
|
]
|
||||||
|
assert "source_path" not in result
|
||||||
|
assert "target_path" not in result
|
||||||
|
assert result["remote_url"] == "https://github.com/user/repo.git"
|
||||||
|
assert result["branch"] == "main"
|
||||||
|
|
||||||
|
def test_already_mappings(self) -> None:
|
||||||
|
"""Entry already with mappings is left unchanged."""
|
||||||
|
entry = {
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"branch": "main",
|
||||||
|
"mappings": [
|
||||||
|
{"source_path": "a", "target_path": "/a"},
|
||||||
|
{"source_path": "b", "target_path": "/b"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
result = _normalize_git_mount(entry)
|
||||||
|
assert result["mappings"] == [
|
||||||
|
{"source_path": "a", "target_path": "/a"},
|
||||||
|
{"source_path": "b", "target_path": "/b"},
|
||||||
|
]
|
||||||
|
assert "source_path" not in result
|
||||||
|
assert "target_path" not in result
|
||||||
|
|
||||||
|
def test_missing_target_path_no_mappings(self) -> None:
|
||||||
|
"""Entry with source_path but no target_path creates empty mappings."""
|
||||||
|
entry = {
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": "src",
|
||||||
|
}
|
||||||
|
result = _normalize_git_mount(entry)
|
||||||
|
assert "mappings" not in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveGitMountMappings:
|
||||||
|
"""Tests for _resolve_git_mount_mappings."""
|
||||||
|
|
||||||
|
def test_single_mapping(self) -> None:
|
||||||
|
"""A single mapping produces one volume mount."""
|
||||||
|
with tempfile.TemporaryDirectory() as repo_path:
|
||||||
|
os.makedirs(os.path.join(repo_path, "packages", "api"))
|
||||||
|
mappings = [
|
||||||
|
{"source_path": "packages/api", "target_path": "/app/api"},
|
||||||
|
]
|
||||||
|
result = _resolve_git_mount_mappings(repo_path, mappings, None)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["source"] == os.path.join(repo_path, "packages", "api")
|
||||||
|
assert result[0]["target"] == "/app/api"
|
||||||
|
assert result[0]["type"] == "bind"
|
||||||
|
|
||||||
|
def test_multiple_mappings(self) -> None:
|
||||||
|
"""Multiple mappings from same repo produce multiple mounts."""
|
||||||
|
with tempfile.TemporaryDirectory() as repo_path:
|
||||||
|
os.makedirs(os.path.join(repo_path, "packages", "api"))
|
||||||
|
os.makedirs(os.path.join(repo_path, "packages", "web"))
|
||||||
|
mappings = [
|
||||||
|
{"source_path": "packages/api", "target_path": "/app/api"},
|
||||||
|
{"source_path": "packages/web", "target_path": "/app/web"},
|
||||||
|
]
|
||||||
|
result = _resolve_git_mount_mappings(repo_path, mappings, None)
|
||||||
|
assert len(result) == 2
|
||||||
|
targets = {r["target"] for r in result}
|
||||||
|
assert targets == {"/app/api", "/app/web"}
|
||||||
|
|
||||||
|
def test_relative_target_path(self) -> None:
|
||||||
|
"""Relative target_path is resolved against working_directory."""
|
||||||
|
with tempfile.TemporaryDirectory() as repo_path:
|
||||||
|
os.makedirs(os.path.join(repo_path, "src"))
|
||||||
|
mappings = [
|
||||||
|
{"source_path": "src", "target_path": "code"},
|
||||||
|
]
|
||||||
|
result = _resolve_git_mount_mappings(repo_path, mappings, "/workspace")
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["target"] == "/workspace/code"
|
||||||
|
|
||||||
|
def test_glob_expansion(self) -> None:
|
||||||
|
"""Glob patterns in source_path are expanded."""
|
||||||
|
with tempfile.TemporaryDirectory() as repo_path:
|
||||||
|
os.makedirs(os.path.join(repo_path, "packages", "api"))
|
||||||
|
os.makedirs(os.path.join(repo_path, "packages", "web"))
|
||||||
|
mappings = [
|
||||||
|
{"source_path": "packages/*", "target_path": "/app/packages"},
|
||||||
|
]
|
||||||
|
result = _resolve_git_mount_mappings(repo_path, mappings, None)
|
||||||
|
assert len(result) == 2
|
||||||
|
targets = {r["target"] for r in result}
|
||||||
|
assert targets == {
|
||||||
|
os.path.join("/app/packages", "packages", "api"),
|
||||||
|
os.path.join("/app/packages", "packages", "web"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_missing_target_path_skipped(self) -> None:
|
||||||
|
"""Mapping without target_path is skipped."""
|
||||||
|
with tempfile.TemporaryDirectory() as repo_path:
|
||||||
|
mappings = [
|
||||||
|
{"source_path": "src"},
|
||||||
|
]
|
||||||
|
result = _resolve_git_mount_mappings(repo_path, mappings, None)
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
def test_no_working_directory_for_relative_target(self) -> None:
|
||||||
|
"""Relative target without working_directory is skipped."""
|
||||||
|
with tempfile.TemporaryDirectory() as repo_path:
|
||||||
|
os.makedirs(os.path.join(repo_path, "src"))
|
||||||
|
mappings = [
|
||||||
|
{"source_path": "src", "target_path": "code"},
|
||||||
|
]
|
||||||
|
result = _resolve_git_mount_mappings(repo_path, mappings, None)
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveSingleGitMount:
|
||||||
|
"""Tests for _resolve_single_git_mount."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_remote_url(self) -> None:
|
||||||
|
"""Git mount without remote_url returns empty list."""
|
||||||
|
result = await _resolve_single_git_mount(
|
||||||
|
MagicMock(),
|
||||||
|
{"mappings": [{"source_path": ".", "target_path": "/app"}]},
|
||||||
|
"/tmp",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_instance_dir(self) -> None:
|
||||||
|
"""Git mount without instance_dir returns empty list."""
|
||||||
|
result = await _resolve_single_git_mount(
|
||||||
|
MagicMock(),
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"mappings": [{"source_path": ".", "target_path": "/app"}],
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_format_normalized(self) -> None:
|
||||||
|
"""Legacy format is normalized and resolved."""
|
||||||
|
with tempfile.TemporaryDirectory() as instance_dir:
|
||||||
|
with patch(
|
||||||
|
"src.api.tool_instances._clone_git_repo",
|
||||||
|
return_value=os.path.join(instance_dir, "repo-clone"),
|
||||||
|
):
|
||||||
|
os.makedirs(os.path.join(instance_dir, "repo-clone", "src"))
|
||||||
|
result = await _resolve_single_git_mount(
|
||||||
|
MagicMock(),
|
||||||
|
{
|
||||||
|
"remote_url": "https://github.com/user/repo.git",
|
||||||
|
"source_path": "src",
|
||||||
|
"target_path": "/app/src",
|
||||||
|
},
|
||||||
|
instance_dir,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["target"] == "/app/src"
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpandGlobSource:
|
||||||
|
"""Tests for _expand_glob_source."""
|
||||||
|
|
||||||
|
def test_no_glob(self) -> None:
|
||||||
|
"""Non-glob path returns single item if exists."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "file.txt")
|
||||||
|
open(path, "w").close()
|
||||||
|
result = _expand_glob_source(path, tmp)
|
||||||
|
assert result == [path]
|
||||||
|
|
||||||
|
def test_no_glob_missing(self) -> None:
|
||||||
|
"""Non-glob path that doesn't exist returns empty list."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "missing.txt")
|
||||||
|
result = _expand_glob_source(path, tmp)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_glob_pattern(self) -> None:
|
||||||
|
"""Glob pattern expands to matched paths."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
open(os.path.join(tmp, "a.txt"), "w").close()
|
||||||
|
open(os.path.join(tmp, "b.txt"), "w").close()
|
||||||
|
result = _expand_glob_source(os.path.join(tmp, "*.txt"), tmp)
|
||||||
|
assert len(result) == 2
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Unit tests for GitService."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.git_service import GitService
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitServiceClone:
|
||||||
|
"""Tests for GitService.clone."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clone_success(self):
|
||||||
|
"""Clone succeeds when git returns 0."""
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
mock_proc.communicate.return_value = (b"", b"")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"asyncio.create_subprocess_exec", return_value=mock_proc
|
||||||
|
) as mock_exec:
|
||||||
|
await GitService.clone(
|
||||||
|
"https://github.com/test/repo.git", "main", "/tmp/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_exec.assert_called_once_with(
|
||||||
|
"git",
|
||||||
|
"clone",
|
||||||
|
"--branch",
|
||||||
|
"main",
|
||||||
|
"--single-branch",
|
||||||
|
"https://github.com/test/repo.git",
|
||||||
|
"/tmp/ws",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clone_failure(self):
|
||||||
|
"""Clone raises RuntimeError when git fails."""
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.returncode = 1
|
||||||
|
mock_proc.communicate.return_value = (b"", b"fatal: repository not found")
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
|
||||||
|
with pytest.raises(RuntimeError, match="Git clone failed"):
|
||||||
|
await GitService.clone("https://bad/url.git", "main", "/tmp/ws")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitServiceFetch:
|
||||||
|
"""Tests for GitService.fetch."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_success(self):
|
||||||
|
"""Fetch succeeds when git returns 0."""
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
mock_proc.communicate.return_value = (b"", b"")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"asyncio.create_subprocess_exec", return_value=mock_proc
|
||||||
|
) as mock_exec:
|
||||||
|
await GitService.fetch("/tmp/repo")
|
||||||
|
|
||||||
|
mock_exec.assert_called_once_with(
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
"/tmp/repo",
|
||||||
|
"fetch",
|
||||||
|
"origin",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_failure(self):
|
||||||
|
"""Fetch raises RuntimeError when git fails."""
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.returncode = 128
|
||||||
|
mock_proc.communicate.return_value = (b"", b"fatal: not a git repository")
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
|
||||||
|
with pytest.raises(RuntimeError, match="Git fetch failed"):
|
||||||
|
await GitService.fetch("/not/a/repo")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitServicePull:
|
||||||
|
"""Tests for GitService.pull."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pull_success(self):
|
||||||
|
"""Pull succeeds when git returns 0."""
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
mock_proc.communicate.return_value = (b"Already up to date.", b"")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"asyncio.create_subprocess_exec", return_value=mock_proc
|
||||||
|
) as mock_exec:
|
||||||
|
await GitService.pull("/tmp/repo", "feature-branch")
|
||||||
|
|
||||||
|
mock_exec.assert_called_once_with(
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
"/tmp/repo",
|
||||||
|
"pull",
|
||||||
|
"origin",
|
||||||
|
"feature-branch",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitServiceBranchExistsRemotely:
|
||||||
|
"""Tests for GitService.branch_exists_remotely."""
|
||||||
|
|
||||||
|
def test_branch_exists(self):
|
||||||
|
"""Returns True when branch exists on remote."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.returncode = 0
|
||||||
|
mock_result.stdout = "abc123 refs/heads/main\n"
|
||||||
|
|
||||||
|
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||||
|
result = GitService.branch_exists_remotely("/tmp/repo", "main")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
["git", "-C", "/tmp/repo", "ls-remote", "--heads", "origin", "main"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_branch_not_exists(self):
|
||||||
|
"""Returns False when branch does not exist on remote."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.returncode = 0
|
||||||
|
mock_result.stdout = ""
|
||||||
|
|
||||||
|
with patch("subprocess.run", return_value=mock_result):
|
||||||
|
result = GitService.branch_exists_remotely("/tmp/repo", "deleted-branch")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_ls_remote_fails(self):
|
||||||
|
"""Returns False when ls-remote fails."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.returncode = 128
|
||||||
|
mock_result.stdout = ""
|
||||||
|
|
||||||
|
with patch("subprocess.run", return_value=mock_result):
|
||||||
|
result = GitService.branch_exists_remotely("/tmp/repo", "main")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"""Unit tests for HealthMonitor state-transition logic."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from contextlib import suppress
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.models.health_check import HealthCheck
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.user import User
|
||||||
|
from src.services.event_bus import InstanceEventBus, InstanceEventPayload
|
||||||
|
from src.services.health_monitor import HealthMonitor, HealthSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def event_bus() -> InstanceEventBus:
|
||||||
|
"""Provide a fresh EventBus instance."""
|
||||||
|
bus = InstanceEventBus()
|
||||||
|
bus._reset_for_testing()
|
||||||
|
return bus
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def health_monitor(event_bus: InstanceEventBus) -> HealthMonitor:
|
||||||
|
"""Provide a HealthMonitor with a short poll interval for testing."""
|
||||||
|
monitor = HealthMonitor(event_bus)
|
||||||
|
monitor.POLL_INTERVAL_SECONDS = 0.1
|
||||||
|
return monitor
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_running_instance(db_session) -> ToolInstance:
|
||||||
|
"""Helper to create a user and a running tool instance."""
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="hm@example.com",
|
||||||
|
name="HM Test",
|
||||||
|
authentik_id="auth-hm",
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="hm-test-instance",
|
||||||
|
display_name="HM Test Instance",
|
||||||
|
tool_type_id=uuid.uuid4(),
|
||||||
|
repository_id=uuid.uuid4(),
|
||||||
|
project_id=uuid.uuid4(),
|
||||||
|
owner_id=user.id,
|
||||||
|
status="running",
|
||||||
|
container_id="container123",
|
||||||
|
public_url="https://example.trycloudflare.com",
|
||||||
|
)
|
||||||
|
db_session.add(instance)
|
||||||
|
await db_session.commit()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_detects_container_crash(
|
||||||
|
db_session,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
health_monitor: HealthMonitor,
|
||||||
|
) -> None:
|
||||||
|
"""Monitor should detect exited container and publish error event."""
|
||||||
|
instance = await _create_running_instance(db_session)
|
||||||
|
|
||||||
|
events_captured: list[InstanceEventPayload] = []
|
||||||
|
|
||||||
|
def capture_event(payload: InstanceEventPayload) -> None:
|
||||||
|
events_captured.append(payload)
|
||||||
|
|
||||||
|
event_bus.subscribe("instance.error", capture_event)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"src.services.health_monitor.get_container_status",
|
||||||
|
return_value={"status": "exited", "exit_code": 137, "health": None},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"src.services.health_monitor.check_tunnel_health",
|
||||||
|
return_value={"healthy": False, "tunnel_status": "not_applicable"},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await health_monitor._check_instance(db_session, instance)
|
||||||
|
|
||||||
|
# Refresh instance from DB
|
||||||
|
await db_session.refresh(instance)
|
||||||
|
assert instance.status == "error"
|
||||||
|
|
||||||
|
# Event published
|
||||||
|
assert len(events_captured) == 1
|
||||||
|
assert events_captured[0]["event"] == "instance.error"
|
||||||
|
assert events_captured[0]["status"] == "error"
|
||||||
|
assert events_captured[0]["metadata"]["exit_code"] == 137
|
||||||
|
|
||||||
|
# Health check row inserted
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(HealthCheck).where(HealthCheck.instance_id == instance.id)
|
||||||
|
)
|
||||||
|
check = result.scalar_one()
|
||||||
|
assert check.container_status == "exited"
|
||||||
|
assert check.exit_code == 137
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_detects_tunnel_failure(
|
||||||
|
db_session,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
health_monitor: HealthMonitor,
|
||||||
|
) -> None:
|
||||||
|
"""Monitor should detect tunnel failure and mark unhealthy."""
|
||||||
|
instance = await _create_running_instance(db_session)
|
||||||
|
|
||||||
|
events_captured: list[InstanceEventPayload] = []
|
||||||
|
|
||||||
|
def capture_event(payload: InstanceEventPayload) -> None:
|
||||||
|
events_captured.append(payload)
|
||||||
|
|
||||||
|
event_bus.subscribe("instance.health_changed", capture_event)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"src.services.health_monitor.get_container_status",
|
||||||
|
return_value={"status": "running", "exit_code": None, "health": "healthy"},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"src.services.health_monitor.check_tunnel_health",
|
||||||
|
return_value={
|
||||||
|
"healthy": False,
|
||||||
|
"tunnel_status": "error_response",
|
||||||
|
"status_code": 502,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await health_monitor._check_instance(db_session, instance)
|
||||||
|
|
||||||
|
await db_session.refresh(instance)
|
||||||
|
assert instance.status == "unhealthy"
|
||||||
|
|
||||||
|
assert len(events_captured) == 1
|
||||||
|
assert events_captured[0]["event"] == "instance.health_changed"
|
||||||
|
assert events_captured[0]["status"] == "unhealthy"
|
||||||
|
assert events_captured[0]["metadata"]["previous_status"] == "running"
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(HealthCheck).where(HealthCheck.instance_id == instance.id)
|
||||||
|
)
|
||||||
|
check = result.scalar_one()
|
||||||
|
assert check.tunnel_healthy is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_detects_recovery(
|
||||||
|
db_session,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
health_monitor: HealthMonitor,
|
||||||
|
) -> None:
|
||||||
|
"""Monitor should detect recovery from unhealthy to running."""
|
||||||
|
instance = await _create_running_instance(db_session)
|
||||||
|
instance.status = "unhealthy"
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Seed last known state as unhealthy
|
||||||
|
health_monitor._last_known_state[instance.id] = HealthSnapshot(
|
||||||
|
container_status="running",
|
||||||
|
container_healthy=None,
|
||||||
|
tunnel_healthy=False,
|
||||||
|
exit_code=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
events_captured: list[InstanceEventPayload] = []
|
||||||
|
|
||||||
|
def capture_event(payload: InstanceEventPayload) -> None:
|
||||||
|
events_captured.append(payload)
|
||||||
|
|
||||||
|
event_bus.subscribe("instance.health_changed", capture_event)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"src.services.health_monitor.get_container_status",
|
||||||
|
return_value={"status": "running", "exit_code": None, "health": None},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"src.services.health_monitor.check_tunnel_health",
|
||||||
|
return_value={
|
||||||
|
"healthy": True,
|
||||||
|
"tunnel_status": "healthy",
|
||||||
|
"status_code": 200,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await health_monitor._check_instance(db_session, instance)
|
||||||
|
|
||||||
|
await db_session.refresh(instance)
|
||||||
|
assert instance.status == "running"
|
||||||
|
|
||||||
|
assert len(events_captured) == 1
|
||||||
|
assert events_captured[0]["status"] == "running"
|
||||||
|
assert events_captured[0]["metadata"]["previous_status"] == "unhealthy"
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(HealthCheck).where(HealthCheck.instance_id == instance.id)
|
||||||
|
)
|
||||||
|
check = result.scalar_one()
|
||||||
|
assert check.tunnel_healthy is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_skips_writes_when_no_state_change(
|
||||||
|
db_session,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
health_monitor: HealthMonitor,
|
||||||
|
) -> None:
|
||||||
|
"""Two identical polls should result in only one health_checks row."""
|
||||||
|
instance = await _create_running_instance(db_session)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"src.services.health_monitor.get_container_status",
|
||||||
|
return_value={"status": "running", "exit_code": None, "health": None},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"src.services.health_monitor.check_tunnel_health",
|
||||||
|
return_value={
|
||||||
|
"healthy": True,
|
||||||
|
"tunnel_status": "healthy",
|
||||||
|
"status_code": 200,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await health_monitor._check_instance(db_session, instance)
|
||||||
|
await health_monitor._check_instance(db_session, instance)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(HealthCheck).where(HealthCheck.instance_id == instance.id)
|
||||||
|
)
|
||||||
|
assert len(result.scalars().all()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_docker_exception_resilience(
|
||||||
|
db_session,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
health_monitor: HealthMonitor,
|
||||||
|
) -> None:
|
||||||
|
"""Docker exception should be caught and not propagate."""
|
||||||
|
instance = await _create_running_instance(db_session)
|
||||||
|
|
||||||
|
events_captured: list[InstanceEventPayload] = []
|
||||||
|
|
||||||
|
def capture_event(payload: InstanceEventPayload) -> None:
|
||||||
|
events_captured.append(payload)
|
||||||
|
|
||||||
|
event_bus.subscribe("instance.error", capture_event)
|
||||||
|
event_bus.subscribe("instance.health_changed", capture_event)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.services.health_monitor.get_container_status",
|
||||||
|
side_effect=RuntimeError("docker exploded"),
|
||||||
|
):
|
||||||
|
# Should not raise
|
||||||
|
await health_monitor._check_instance(db_session, instance)
|
||||||
|
|
||||||
|
# No DB writes
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(HealthCheck).where(HealthCheck.instance_id == instance.id)
|
||||||
|
)
|
||||||
|
assert result.scalar_one_or_none() is None
|
||||||
|
|
||||||
|
# No events published
|
||||||
|
assert events_captured == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_monitor_start_stop(health_monitor: HealthMonitor) -> None:
|
||||||
|
"""Start and stop should manage the background task."""
|
||||||
|
health_monitor.start()
|
||||||
|
task = health_monitor._task
|
||||||
|
assert task is not None
|
||||||
|
assert not task.done()
|
||||||
|
|
||||||
|
health_monitor.stop()
|
||||||
|
if task is not None and not task.done():
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
assert task is not None
|
||||||
|
assert task.cancelled() or task.done()
|
||||||
|
assert health_monitor._last_known_state == {}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Unit tests for ~ / $HOME expansion in container paths."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.api.tool_instances import _resolve_git_mount_mappings
|
||||||
|
from src.services.config_profile_resolver import expand_container_path
|
||||||
|
from src.services.manifest_compiler import get_manifest_home_dir
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpandContainerPath:
|
||||||
|
"""Tests for expand_container_path helper."""
|
||||||
|
|
||||||
|
def test_tilde_slash_expands(self) -> None:
|
||||||
|
"""~/foo should expand to home_dir/foo."""
|
||||||
|
assert (
|
||||||
|
expand_container_path("~/workspace", "/home/user") == "/home/user/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_tilde_alone_expands(self) -> None:
|
||||||
|
"""~ should expand to home_dir."""
|
||||||
|
assert expand_container_path("~", "/home/user") == "/home/user"
|
||||||
|
|
||||||
|
def test_dollar_home_slash_expands(self) -> None:
|
||||||
|
"""$HOME/foo should expand to home_dir/foo."""
|
||||||
|
assert (
|
||||||
|
expand_container_path("$HOME/workspace", "/home/user")
|
||||||
|
== "/home/user/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dollar_home_alone_expands(self) -> None:
|
||||||
|
"""$HOME should expand to home_dir."""
|
||||||
|
assert expand_container_path("$HOME", "/home/user") == "/home/user"
|
||||||
|
|
||||||
|
def test_absolute_path_unchanged(self) -> None:
|
||||||
|
"""Absolute paths should not be modified."""
|
||||||
|
assert expand_container_path("/app/workspace", "/home/user") == "/app/workspace"
|
||||||
|
|
||||||
|
def test_relative_path_unchanged(self) -> None:
|
||||||
|
"""Relative paths should not be modified."""
|
||||||
|
assert expand_container_path("workspace", "/home/user") == "workspace"
|
||||||
|
|
||||||
|
def test_tilde_in_middle_unchanged(self) -> None:
|
||||||
|
"""~ in the middle of a path should not expand."""
|
||||||
|
assert expand_container_path("/app/~user", "/home/user") == "/app/~user"
|
||||||
|
|
||||||
|
def test_dollar_home_in_middle_unchanged(self) -> None:
|
||||||
|
"""$HOME in the middle of a path should not expand."""
|
||||||
|
assert expand_container_path("/app/$HOMEuser", "/home/user") == "/app/$HOMEuser"
|
||||||
|
|
||||||
|
def test_root_home(self) -> None:
|
||||||
|
"""Expansion works with /root as home."""
|
||||||
|
assert expand_container_path("~/config", "/root") == "/root/config"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetManifestHomeDir:
|
||||||
|
"""Tests for get_manifest_home_dir helper."""
|
||||||
|
|
||||||
|
def test_with_user_block(self) -> None:
|
||||||
|
"""Manifest with user block returns /home/{name}."""
|
||||||
|
manifest = {"user": {"name": "developer", "uid": 1000, "gid": 1000}}
|
||||||
|
assert get_manifest_home_dir(manifest) == "/home/developer"
|
||||||
|
|
||||||
|
def test_without_user_block(self) -> None:
|
||||||
|
"""Manifest without user block returns /root."""
|
||||||
|
manifest = {"base_image": "ubuntu:24.04"}
|
||||||
|
assert get_manifest_home_dir(manifest) == "/root"
|
||||||
|
|
||||||
|
def test_with_empty_user_name(self) -> None:
|
||||||
|
"""Manifest with empty user name returns /root."""
|
||||||
|
manifest = {"user": {"name": "", "uid": 1000, "gid": 1000}}
|
||||||
|
assert get_manifest_home_dir(manifest) == "/root"
|
||||||
|
|
||||||
|
def test_with_none_user_name(self) -> None:
|
||||||
|
"""Manifest with None user name returns /root."""
|
||||||
|
manifest = {"user": {"name": None, "uid": 1000, "gid": 1000}}
|
||||||
|
assert get_manifest_home_dir(manifest) == "/root"
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveGitMountMappingsExpansion:
|
||||||
|
"""Tests that git mount mapping targets expand ~ and $HOME."""
|
||||||
|
|
||||||
|
def test_tilde_target_expansion(self, tmp_path) -> None:
|
||||||
|
"""Mapping with ~/repo target expands to home dir."""
|
||||||
|
(tmp_path / "src").mkdir()
|
||||||
|
mappings = [{"source_path": "src", "target_path": "~/repo"}]
|
||||||
|
result = _resolve_git_mount_mappings(
|
||||||
|
str(tmp_path), mappings, None, "/home/user"
|
||||||
|
)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["target"] == "/home/user/repo"
|
||||||
|
|
||||||
|
def test_dollar_home_target_expansion(self, tmp_path) -> None:
|
||||||
|
"""Mapping with $HOME/repo target expands to home dir."""
|
||||||
|
(tmp_path / "src").mkdir()
|
||||||
|
mappings = [{"source_path": "src", "target_path": "$HOME/repo"}]
|
||||||
|
result = _resolve_git_mount_mappings(
|
||||||
|
str(tmp_path), mappings, None, "/home/user"
|
||||||
|
)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["target"] == "/home/user/repo"
|
||||||
|
|
||||||
|
def test_absolute_target_unchanged(self, tmp_path) -> None:
|
||||||
|
"""Absolute target paths are not modified."""
|
||||||
|
(tmp_path / "src").mkdir()
|
||||||
|
mappings = [{"source_path": "src", "target_path": "/app/src"}]
|
||||||
|
result = _resolve_git_mount_mappings(
|
||||||
|
str(tmp_path), mappings, None, "/home/user"
|
||||||
|
)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["target"] == "/app/src"
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Unit tests for lifecycle hook helpers."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.lifecycle_hooks import _derive_title, _should_notify
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeriveTitle:
|
||||||
|
"""Tests for _derive_title."""
|
||||||
|
|
||||||
|
def test_known_event_types(self) -> None:
|
||||||
|
assert _derive_title("instance.created") == "Container created"
|
||||||
|
assert _derive_title("instance.started") == "Container started"
|
||||||
|
assert _derive_title("instance.stopped") == "Container stopped"
|
||||||
|
assert _derive_title("instance.restarted") == "Container restarted"
|
||||||
|
assert _derive_title("instance.deleted") == "Container deleted"
|
||||||
|
assert _derive_title("instance.error") == "Container error"
|
||||||
|
assert _derive_title("instance.health_changed") == "Container ready"
|
||||||
|
|
||||||
|
def test_unknown_event_type(self) -> None:
|
||||||
|
assert _derive_title("instance.custom_event") == "Custom Event"
|
||||||
|
|
||||||
|
|
||||||
|
class TestShouldNotify:
|
||||||
|
"""Tests for _should_notify filtering."""
|
||||||
|
|
||||||
|
def test_error_events_are_notified(self) -> None:
|
||||||
|
assert _should_notify("instance.error", "error") is True
|
||||||
|
assert _should_notify("instance.error", None) is True
|
||||||
|
|
||||||
|
def test_health_changed_running_is_notified(self) -> None:
|
||||||
|
assert _should_notify("instance.health_changed", "running") is True
|
||||||
|
|
||||||
|
def test_created_started_stopped_restarted_deleted_filtered(self) -> None:
|
||||||
|
for event in [
|
||||||
|
"instance.created",
|
||||||
|
"instance.started",
|
||||||
|
"instance.stopped",
|
||||||
|
"instance.restarted",
|
||||||
|
"instance.deleted",
|
||||||
|
]:
|
||||||
|
assert _should_notify(event, "pending") is False
|
||||||
|
assert _should_notify(event, "running") is False
|
||||||
|
assert _should_notify(event, None) is False
|
||||||
|
|
||||||
|
def test_health_changed_non_running_filtered(self) -> None:
|
||||||
|
assert _should_notify("instance.health_changed", "unhealthy") is False
|
||||||
|
assert _should_notify("instance.health_changed", "starting") is False
|
||||||
|
assert _should_notify("instance.health_changed", None) is False
|
||||||
@@ -8,6 +8,7 @@ from src.services.manifest_compiler import (
|
|||||||
compile_entrypoint,
|
compile_entrypoint,
|
||||||
compute_image_tag,
|
compute_image_tag,
|
||||||
deep_merge,
|
deep_merge,
|
||||||
|
get_manifest_home_dir,
|
||||||
merge_with_config,
|
merge_with_config,
|
||||||
resolve_base,
|
resolve_base,
|
||||||
)
|
)
|
||||||
@@ -161,6 +162,38 @@ class TestCompileDockerfile:
|
|||||||
df = compile_dockerfile(manifest)
|
df = compile_dockerfile(manifest)
|
||||||
assert 'CMD ["/bin/bash"]' in df
|
assert 'CMD ["/bin/bash"]' in df
|
||||||
|
|
||||||
|
def test_sets_home_env_for_user(self) -> None:
|
||||||
|
manifest = {
|
||||||
|
"base_image": "ubuntu:24.04",
|
||||||
|
"name": "test",
|
||||||
|
"user": {"name": "dev", "uid": 1001, "gid": 1001},
|
||||||
|
}
|
||||||
|
df = compile_dockerfile(manifest)
|
||||||
|
assert "ENV HOME=/home/dev" in df
|
||||||
|
assert "ENV USER=dev" in df
|
||||||
|
|
||||||
|
def test_no_home_env_without_user(self) -> None:
|
||||||
|
manifest = {"base_image": "ubuntu:24.04", "name": "test"}
|
||||||
|
df = compile_dockerfile(manifest)
|
||||||
|
assert "ENV HOME=" not in df
|
||||||
|
assert "ENV USER=" not in df
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetManifestHomeDir:
|
||||||
|
"""Tests for get_manifest_home_dir."""
|
||||||
|
|
||||||
|
def test_with_user_name(self) -> None:
|
||||||
|
manifest = {"user": {"name": "dev", "uid": 1001, "gid": 1001}}
|
||||||
|
assert get_manifest_home_dir(manifest) == "/home/dev"
|
||||||
|
|
||||||
|
def test_without_user(self) -> None:
|
||||||
|
manifest = {"base_image": "ubuntu:24.04"}
|
||||||
|
assert get_manifest_home_dir(manifest) == "/root"
|
||||||
|
|
||||||
|
def test_with_empty_user_name(self) -> None:
|
||||||
|
manifest = {"user": {"name": "", "uid": 1001, "gid": 1001}}
|
||||||
|
assert get_manifest_home_dir(manifest) == "/root"
|
||||||
|
|
||||||
|
|
||||||
class TestCompileEntrypoint:
|
class TestCompileEntrypoint:
|
||||||
"""Tests for compile_entrypoint."""
|
"""Tests for compile_entrypoint."""
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""Unit tests for monitoring models and migration compatibility."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.models.health_check import HealthCheck
|
||||||
|
from src.models.instance_event import InstanceEvent
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
|
from src.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_instance_event_creation(db_session) -> None:
|
||||||
|
"""InstanceEvent model can be created and persisted."""
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="test@example.com",
|
||||||
|
name="Test",
|
||||||
|
authentik_id="auth-1",
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-instance",
|
||||||
|
display_name="Test Instance",
|
||||||
|
tool_type_id=uuid.uuid4(),
|
||||||
|
repository_id=uuid.uuid4(),
|
||||||
|
project_id=uuid.uuid4(),
|
||||||
|
owner_id=user.id,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db_session.add(instance)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
event = InstanceEvent(
|
||||||
|
instance_id=instance.id,
|
||||||
|
event_type="started",
|
||||||
|
status="starting",
|
||||||
|
message="Container starting...",
|
||||||
|
created_by=user.id,
|
||||||
|
event_metadata={"previous_status": "pending"},
|
||||||
|
)
|
||||||
|
db_session.add(event)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(event)
|
||||||
|
|
||||||
|
assert event.id is not None
|
||||||
|
assert event.instance_id == instance.id
|
||||||
|
assert event.event_type == "started"
|
||||||
|
assert event.status == "starting"
|
||||||
|
assert event.created_by == user.id
|
||||||
|
assert event.event_metadata == {"previous_status": "pending"}
|
||||||
|
assert isinstance(event.created_at, datetime)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_health_check_creation(db_session) -> None:
|
||||||
|
"""HealthCheck model can be created and persisted."""
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="test2@example.com",
|
||||||
|
name="Test2",
|
||||||
|
authentik_id="auth-2",
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-instance-2",
|
||||||
|
display_name="Test Instance 2",
|
||||||
|
tool_type_id=uuid.uuid4(),
|
||||||
|
repository_id=uuid.uuid4(),
|
||||||
|
project_id=uuid.uuid4(),
|
||||||
|
owner_id=user.id,
|
||||||
|
status="running",
|
||||||
|
)
|
||||||
|
db_session.add(instance)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
check = HealthCheck(
|
||||||
|
instance_id=instance.id,
|
||||||
|
container_status="running",
|
||||||
|
container_healthy=True,
|
||||||
|
tunnel_healthy=True,
|
||||||
|
exit_code=None,
|
||||||
|
probe_status="passed",
|
||||||
|
probe_output="OK",
|
||||||
|
)
|
||||||
|
db_session.add(check)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(check)
|
||||||
|
|
||||||
|
assert check.id is not None
|
||||||
|
assert check.instance_id == instance.id
|
||||||
|
assert check.container_status == "running"
|
||||||
|
assert check.container_healthy is True
|
||||||
|
assert check.tunnel_healthy is True
|
||||||
|
assert isinstance(check.checked_at, datetime)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_instance_event_query_by_instance(db_session) -> None:
|
||||||
|
"""InstanceEvent rows can be queried by instance_id."""
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="test3@example.com",
|
||||||
|
name="Test3",
|
||||||
|
authentik_id="auth-3",
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-instance-3",
|
||||||
|
display_name="Test Instance 3",
|
||||||
|
tool_type_id=uuid.uuid4(),
|
||||||
|
repository_id=uuid.uuid4(),
|
||||||
|
project_id=uuid.uuid4(),
|
||||||
|
owner_id=user.id,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db_session.add(instance)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
event = InstanceEvent(
|
||||||
|
instance_id=instance.id,
|
||||||
|
event_type="created",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db_session.add(event)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(InstanceEvent).where(InstanceEvent.instance_id == instance.id)
|
||||||
|
)
|
||||||
|
assert result.scalar_one() is not None
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"""Unit tests for NotificationService."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.notification import Notification
|
||||||
|
from src.models.user import User
|
||||||
|
from src.services.notification_service import NotificationService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def notification_service() -> NotificationService:
|
||||||
|
return NotificationService()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def user_a(db_session: AsyncSession) -> User:
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="user-a@headquarter.local",
|
||||||
|
name="User A",
|
||||||
|
authentik_id=f"authentik-{uuid.uuid4()}",
|
||||||
|
avatar_url=None,
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def user_b(db_session: AsyncSession) -> User:
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email="user-b@headquarter.local",
|
||||||
|
name="User B",
|
||||||
|
authentik_id=f"authentik-{uuid.uuid4()}",
|
||||||
|
avatar_url=None,
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_notification(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
notification = await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title="Container started",
|
||||||
|
message="Instance is running",
|
||||||
|
source_type="tool_instances",
|
||||||
|
source_id=uuid.uuid4(),
|
||||||
|
metadata={"key": "value"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert notification.user_id == user_a.id
|
||||||
|
assert notification.category == "instance"
|
||||||
|
assert notification.severity == "info"
|
||||||
|
assert notification.title == "Container started"
|
||||||
|
assert notification.message == "Instance is running"
|
||||||
|
assert notification.source_type == "tool_instances"
|
||||||
|
assert notification.notification_metadata == {"key": "value"}
|
||||||
|
assert notification.read_at is None
|
||||||
|
assert notification.dismissed_at is None
|
||||||
|
assert notification.created_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_notifications_orders_by_created_at_desc(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
n1 = await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title="First"
|
||||||
|
)
|
||||||
|
n1.created_at = datetime.now(timezone.utc) - timedelta(seconds=2)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(n1)
|
||||||
|
|
||||||
|
n2 = await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title="Second"
|
||||||
|
)
|
||||||
|
n2.created_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(n2)
|
||||||
|
|
||||||
|
n3 = await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title="Third"
|
||||||
|
)
|
||||||
|
|
||||||
|
items, total = await notification_service.list_notifications(db_session, user_a.id)
|
||||||
|
|
||||||
|
assert total == 3
|
||||||
|
assert [item.id for item in items] == [n3.id, n2.id, n1.id]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_notifications_excludes_dismissed(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
n1 = await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title="Visible"
|
||||||
|
)
|
||||||
|
n2 = await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title="Dismissed"
|
||||||
|
)
|
||||||
|
await notification_service.dismiss(db_session, n2.id, user_a.id)
|
||||||
|
|
||||||
|
items, total = await notification_service.list_notifications(db_session, user_a.id)
|
||||||
|
|
||||||
|
assert total == 1
|
||||||
|
assert items[0].id == n1.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_notifications_unread_only(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
n1 = await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title="Unread"
|
||||||
|
)
|
||||||
|
n2 = await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title="Read"
|
||||||
|
)
|
||||||
|
await notification_service.mark_read(db_session, n2.id, user_a.id)
|
||||||
|
|
||||||
|
items, total = await notification_service.list_notifications(
|
||||||
|
db_session, user_a.id, unread_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert total == 1
|
||||||
|
assert items[0].id == n1.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_unread_count(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
for i in range(5):
|
||||||
|
n = await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title=f"Notification {i}",
|
||||||
|
)
|
||||||
|
if i >= 3:
|
||||||
|
await notification_service.mark_read(db_session, n.id, user_a.id)
|
||||||
|
|
||||||
|
count = await notification_service.get_unread_count(db_session, user_a.id)
|
||||||
|
|
||||||
|
assert count == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mark_read_sets_read_at(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
n = await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title="Unread"
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await notification_service.mark_read(db_session, n.id, user_a.id)
|
||||||
|
|
||||||
|
assert updated.read_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mark_all_read_affects_all_unread(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
for i in range(4):
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title=f"Notification {i}",
|
||||||
|
)
|
||||||
|
|
||||||
|
marked = await notification_service.mark_all_read(db_session, user_a.id)
|
||||||
|
|
||||||
|
assert marked == 4
|
||||||
|
count = await notification_service.get_unread_count(db_session, user_a.id)
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dismiss_sets_dismissed_at(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
n = await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title="To dismiss"
|
||||||
|
)
|
||||||
|
|
||||||
|
await notification_service.dismiss(db_session, n.id, user_a.id)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(Notification).where(Notification.id == n.id)
|
||||||
|
)
|
||||||
|
row = result.scalar_one()
|
||||||
|
assert row.dismissed_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mark_read_wrong_owner_raises(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
user_b: User,
|
||||||
|
) -> None:
|
||||||
|
n = await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title="Owned by A"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Notification not found"):
|
||||||
|
await notification_service.mark_read(db_session, n.id, user_b.id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dismiss_wrong_owner_raises(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
user_b: User,
|
||||||
|
) -> None:
|
||||||
|
n = await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title="Owned by A"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Notification not found"):
|
||||||
|
await notification_service.dismiss(db_session, n.id, user_b.id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_notifications_mute_categories(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title="Instance"
|
||||||
|
)
|
||||||
|
n2 = await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="system", severity="info", title="System"
|
||||||
|
)
|
||||||
|
|
||||||
|
items, total = await notification_service.list_notifications(
|
||||||
|
db_session, user_a.id, mute_categories=["instance"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert total == 1
|
||||||
|
assert items[0].id == n2.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_unread_count_excludes_dismissed(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
n = await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title="Unread dismissed",
|
||||||
|
)
|
||||||
|
await notification_service.dismiss(db_session, n.id, user_a.id)
|
||||||
|
|
||||||
|
count = await notification_service.get_unread_count(db_session, user_a.id)
|
||||||
|
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dismiss_all_affects_all_non_dismissed(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
) -> None:
|
||||||
|
for i in range(4):
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session,
|
||||||
|
user_a.id,
|
||||||
|
category="instance",
|
||||||
|
severity="info",
|
||||||
|
title=f"Notification {i}",
|
||||||
|
)
|
||||||
|
|
||||||
|
cleared = await notification_service.dismiss_all(db_session, user_a.id)
|
||||||
|
|
||||||
|
assert cleared == 4
|
||||||
|
items, total = await notification_service.list_notifications(db_session, user_a.id)
|
||||||
|
assert total == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dismiss_all_affects_only_caller(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
user_b: User,
|
||||||
|
) -> None:
|
||||||
|
for i in range(3):
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title=f"A-{i}"
|
||||||
|
)
|
||||||
|
for i in range(2):
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session, user_b.id, category="instance", severity="info", title=f"B-{i}"
|
||||||
|
)
|
||||||
|
|
||||||
|
cleared = await notification_service.dismiss_all(db_session, user_a.id)
|
||||||
|
|
||||||
|
assert cleared == 3
|
||||||
|
items_a, total_a = await notification_service.list_notifications(
|
||||||
|
db_session, user_a.id
|
||||||
|
)
|
||||||
|
items_b, total_b = await notification_service.list_notifications(
|
||||||
|
db_session, user_b.id
|
||||||
|
)
|
||||||
|
assert total_a == 0
|
||||||
|
assert total_b == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mark_all_read_affects_only_caller(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
notification_service: NotificationService,
|
||||||
|
user_a: User,
|
||||||
|
user_b: User,
|
||||||
|
) -> None:
|
||||||
|
for i in range(3):
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session, user_a.id, category="instance", severity="info", title=f"A-{i}"
|
||||||
|
)
|
||||||
|
for i in range(2):
|
||||||
|
await notification_service.create_notification(
|
||||||
|
db_session, user_b.id, category="instance", severity="info", title=f"B-{i}"
|
||||||
|
)
|
||||||
|
|
||||||
|
marked = await notification_service.mark_all_read(db_session, user_a.id)
|
||||||
|
|
||||||
|
assert marked == 3
|
||||||
|
count_a = await notification_service.get_unread_count(db_session, user_a.id)
|
||||||
|
count_b = await notification_service.get_unread_count(db_session, user_b.id)
|
||||||
|
assert count_a == 0
|
||||||
|
assert count_b == 2
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Unit tests for notification API route ordering."""
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from src.api.notifications import router as notifications_router
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_notifications_route_order() -> None:
|
||||||
|
"""DELETE /notifications must match before DELETE /notifications/{id}.
|
||||||
|
|
||||||
|
FastAPI matches routes in declaration order. The bulk clear endpoint
|
||||||
|
(DELETE /notifications) must be registered before the single dismiss
|
||||||
|
endpoint (DELETE /notifications/{notification_id}) or the path
|
||||||
|
parameter route will intercept the bulk route.
|
||||||
|
"""
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(notifications_router)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# Verify the bulk delete route exists and returns the expected schema
|
||||||
|
# (it will 401 without auth, but that's fine — we just need to confirm
|
||||||
|
# routing doesn't hit the UUID-parameter route first)
|
||||||
|
response = client.delete("/notifications")
|
||||||
|
# Should get 401 (unauthenticated), NOT 422 (UUID parse error)
|
||||||
|
assert response.status_code == 401, (
|
||||||
|
f"Expected 401 (auth required), got {response.status_code}. "
|
||||||
|
f"Route order may be wrong — DELETE /notifications matched "
|
||||||
|
f"DELETE /notifications/{{notification_id}} instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the single dismiss route still works (also 401 without auth)
|
||||||
|
response = client.delete("/notifications/12345678-1234-1234-1234-123456789abc")
|
||||||
|
assert response.status_code == 401
|
||||||
@@ -7,6 +7,7 @@ import pytest
|
|||||||
from src.services.permission_fixer import (
|
from src.services.permission_fixer import (
|
||||||
PermissionFixError,
|
PermissionFixError,
|
||||||
apply_mount_permissions,
|
apply_mount_permissions,
|
||||||
|
apply_ssh_permissions,
|
||||||
check_root_user_available,
|
check_root_user_available,
|
||||||
_run_in_container,
|
_run_in_container,
|
||||||
)
|
)
|
||||||
@@ -63,6 +64,24 @@ class TestApplyMountPermissions:
|
|||||||
"find /home/user/.ssh -type f -exec chmod 0600" in file_mode_call[0][1][2]
|
"find /home/user/.ssh -type f -exec chmod 0600" in file_mode_call[0][1][2]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@patch("src.services.permission_fixer._run_in_container")
|
||||||
|
def test_skips_readonly_mount(self, mock_run) -> None:
|
||||||
|
mounts = [
|
||||||
|
{
|
||||||
|
"name": "ssh_keys",
|
||||||
|
"target": "/home/user/.ssh",
|
||||||
|
"readonly": True,
|
||||||
|
"mode": "0700",
|
||||||
|
"file_mode": "0600",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
results = apply_mount_permissions("abc123", mounts)
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["mount_name"] == "ssh_keys"
|
||||||
|
assert results[0]["success"] is True
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
@patch("src.services.permission_fixer._run_in_container")
|
@patch("src.services.permission_fixer._run_in_container")
|
||||||
def test_skips_mount_with_no_policy(self, mock_run) -> None:
|
def test_skips_mount_with_no_policy(self, mock_run) -> None:
|
||||||
mounts = [
|
mounts = [
|
||||||
@@ -132,6 +151,79 @@ class TestRunInContainer:
|
|||||||
_run_in_container("abc123", ["chown", "x"], 10)
|
_run_in_container("abc123", ["chown", "x"], 10)
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplySshPermissions:
|
||||||
|
"""Tests for apply_ssh_permissions."""
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_applies_chown_chmod_and_file_mode(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
result = apply_ssh_permissions("abc123", "/home/user/.ssh", "user")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
# 3 fix commands + 3 verification commands
|
||||||
|
assert mock_run.call_count == 6
|
||||||
|
chown_cmd = mock_run.call_args_list[0][0][0]
|
||||||
|
chmod_cmd = mock_run.call_args_list[1][0][0]
|
||||||
|
file_mode_cmd = mock_run.call_args_list[2][0][0]
|
||||||
|
|
||||||
|
assert chown_cmd == [
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
"--user",
|
||||||
|
"root",
|
||||||
|
"abc123",
|
||||||
|
"chown",
|
||||||
|
"-R",
|
||||||
|
"user:user",
|
||||||
|
"/home/user/.ssh",
|
||||||
|
]
|
||||||
|
assert chmod_cmd == [
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
"--user",
|
||||||
|
"root",
|
||||||
|
"abc123",
|
||||||
|
"chmod",
|
||||||
|
"700",
|
||||||
|
"/home/user/.ssh",
|
||||||
|
]
|
||||||
|
assert file_mode_cmd[0] == "docker"
|
||||||
|
assert (
|
||||||
|
"find /home/user/.ssh -name 'id_*' -type f -exec chmod 600"
|
||||||
|
in file_mode_cmd[-1]
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_uses_root_user(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
result = apply_ssh_permissions("abc123", "/root/.ssh", "root")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
chown_cmd = mock_run.call_args_list[0][0][0]
|
||||||
|
assert chown_cmd == [
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
"--user",
|
||||||
|
"root",
|
||||||
|
"abc123",
|
||||||
|
"chown",
|
||||||
|
"-R",
|
||||||
|
"root:root",
|
||||||
|
"/root/.ssh",
|
||||||
|
]
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_reports_failure(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=1, stdout="", stderr="chown failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = apply_ssh_permissions("abc123", "/home/user/.ssh", "user")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "chown failed" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
class TestCheckRootUserAvailable:
|
class TestCheckRootUserAvailable:
|
||||||
"""Tests for check_root_user_available."""
|
"""Tests for check_root_user_available."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Unit tests for SSH key preparation."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.ssh_keys import prepare_ssh_key_files
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrepareSshKeyFiles:
|
||||||
|
"""Tests for prepare_ssh_key_files."""
|
||||||
|
|
||||||
|
@patch("src.services.ssh_keys._get_fernet")
|
||||||
|
def test_creates_files_with_default_permissions(
|
||||||
|
self, mock_fernet, tmp_path
|
||||||
|
) -> None:
|
||||||
|
mock_fernet.return_value.decrypt.return_value = b"private-key-content"
|
||||||
|
ssh_key = MagicMock()
|
||||||
|
ssh_key.private_key_encrypted = "enc"
|
||||||
|
ssh_key.public_key = "ssh-ed25519 AAA test@test"
|
||||||
|
|
||||||
|
ssh_dir = prepare_ssh_key_files(str(tmp_path), ssh_key)
|
||||||
|
|
||||||
|
assert Path(ssh_dir).exists()
|
||||||
|
assert (Path(ssh_dir) / "id_ed25519").exists()
|
||||||
|
assert (Path(ssh_dir) / "id_ed25519.pub").exists()
|
||||||
|
assert (Path(ssh_dir) / "config").exists()
|
||||||
|
assert oct(os.stat(Path(ssh_dir) / "id_ed25519").st_mode)[-3:] == "600"
|
||||||
|
|
||||||
|
@patch("src.services.ssh_keys._get_fernet")
|
||||||
|
def test_sets_ownership_when_uid_gid_provided(self, mock_fernet, tmp_path) -> None:
|
||||||
|
mock_fernet.return_value.decrypt.return_value = b"private-key-content"
|
||||||
|
ssh_key = MagicMock()
|
||||||
|
ssh_key.private_key_encrypted = "enc"
|
||||||
|
ssh_key.public_key = "ssh-ed25519 AAA test@test"
|
||||||
|
|
||||||
|
with patch("os.chown") as mock_chown:
|
||||||
|
ssh_dir = prepare_ssh_key_files(str(tmp_path), ssh_key, uid=1001, gid=1001)
|
||||||
|
|
||||||
|
# os.chown is called for the directory and each of the 3 files
|
||||||
|
assert mock_chown.call_count == 4
|
||||||
|
# First call is the directory
|
||||||
|
assert mock_chown.call_args_list[0][0][1] == 1001
|
||||||
|
assert mock_chown.call_args_list[0][0][2] == 1001
|
||||||
|
|
||||||
|
@patch("src.services.ssh_keys._get_fernet")
|
||||||
|
def test_gracefully_handles_permission_error_on_chown(
|
||||||
|
self, mock_fernet, tmp_path
|
||||||
|
) -> None:
|
||||||
|
mock_fernet.return_value.decrypt.return_value = b"private-key-content"
|
||||||
|
ssh_key = MagicMock()
|
||||||
|
ssh_key.private_key_encrypted = "enc"
|
||||||
|
ssh_key.public_key = "ssh-ed25519 AAA test@test"
|
||||||
|
|
||||||
|
with patch("os.chown", side_effect=PermissionError("not allowed")):
|
||||||
|
# Should not raise
|
||||||
|
ssh_dir = prepare_ssh_key_files(str(tmp_path), ssh_key, uid=1001, gid=1001)
|
||||||
|
|
||||||
|
assert Path(ssh_dir).exists()
|
||||||
@@ -77,7 +77,9 @@ def mock_session(fake_user_id, fake_project_id, fake_repo_id, fake_tool_type_id)
|
|||||||
|
|
||||||
session.get.side_effect = _get
|
session.get.side_effect = _get
|
||||||
session.add = MagicMock(side_effect=_add)
|
session.add = MagicMock(side_effect=_add)
|
||||||
session.execute.return_value = MagicMock(scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))))
|
session.execute.return_value = MagicMock(
|
||||||
|
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
|
||||||
|
)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
@@ -143,10 +145,12 @@ class TestCreateInstanceDockerfileLegacy:
|
|||||||
data = MagicMock()
|
data = MagicMock()
|
||||||
data.tool_type_id = str(fake_tool_type_id)
|
data.tool_type_id = str(fake_tool_type_id)
|
||||||
data.display_name = None
|
data.display_name = None
|
||||||
|
data.workspace_id = None
|
||||||
data.clone_mode = "mount"
|
data.clone_mode = "mount"
|
||||||
data.branch = None
|
data.branch = None
|
||||||
data.new_branch = None
|
data.new_branch = None
|
||||||
data.config_profile_id = None
|
data.config_profile_id = None
|
||||||
|
data.ssh_key_ids = []
|
||||||
|
|
||||||
result = await create_instance(
|
result = await create_instance(
|
||||||
project_id=fake_project_id,
|
project_id=fake_project_id,
|
||||||
@@ -223,10 +227,12 @@ class TestCreateInstanceDockerfileLegacy:
|
|||||||
data = MagicMock()
|
data = MagicMock()
|
||||||
data.tool_type_id = str(fake_tool_type_id)
|
data.tool_type_id = str(fake_tool_type_id)
|
||||||
data.display_name = None
|
data.display_name = None
|
||||||
|
data.workspace_id = None
|
||||||
data.clone_mode = "mount"
|
data.clone_mode = "mount"
|
||||||
data.branch = None
|
data.branch = None
|
||||||
data.new_branch = None
|
data.new_branch = None
|
||||||
data.config_profile_id = None
|
data.config_profile_id = None
|
||||||
|
data.ssh_key_ids = []
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
await create_instance(
|
await create_instance(
|
||||||
@@ -303,10 +309,12 @@ class TestCreateInstanceComposeLegacy:
|
|||||||
data = MagicMock()
|
data = MagicMock()
|
||||||
data.tool_type_id = str(fake_tool_type_id)
|
data.tool_type_id = str(fake_tool_type_id)
|
||||||
data.display_name = None
|
data.display_name = None
|
||||||
|
data.workspace_id = None
|
||||||
data.clone_mode = "mount"
|
data.clone_mode = "mount"
|
||||||
data.branch = None
|
data.branch = None
|
||||||
data.new_branch = None
|
data.new_branch = None
|
||||||
data.config_profile_id = None
|
data.config_profile_id = None
|
||||||
|
data.ssh_key_ids = []
|
||||||
|
|
||||||
result = await create_instance(
|
result = await create_instance(
|
||||||
project_id=fake_project_id,
|
project_id=fake_project_id,
|
||||||
@@ -387,10 +395,12 @@ class TestCreateInstanceManifestNotCalledForLegacy:
|
|||||||
data = MagicMock()
|
data = MagicMock()
|
||||||
data.tool_type_id = str(fake_tool_type_id)
|
data.tool_type_id = str(fake_tool_type_id)
|
||||||
data.display_name = None
|
data.display_name = None
|
||||||
|
data.workspace_id = None
|
||||||
data.clone_mode = "mount"
|
data.clone_mode = "mount"
|
||||||
data.branch = None
|
data.branch = None
|
||||||
data.new_branch = None
|
data.new_branch = None
|
||||||
data.config_profile_id = None
|
data.config_profile_id = None
|
||||||
|
data.ssh_key_ids = []
|
||||||
|
|
||||||
await create_instance(
|
await create_instance(
|
||||||
project_id=fake_project_id,
|
project_id=fake_project_id,
|
||||||
@@ -409,8 +419,10 @@ class TestStartInstanceLegacyFallback:
|
|||||||
@patch("src.api.tool_instances.wait_for_container_running")
|
@patch("src.api.tool_instances.wait_for_container_running")
|
||||||
@patch("src.api.tool_instances.execute_compose_command")
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
@patch("src.api.tool_instances.get_container_id")
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
@patch("src.api.tool_instances.get_container_name")
|
|
||||||
@patch("src.api.tool_instances.connect_container_to_network")
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
|
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||||
@patch("src.api.tool_instances._get_user")
|
@patch("src.api.tool_instances._get_user")
|
||||||
@@ -421,8 +433,10 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_get_user,
|
mock_get_user,
|
||||||
mock_prepare_manifest,
|
mock_prepare_manifest,
|
||||||
mock_sanitize,
|
mock_sanitize,
|
||||||
|
mock_ensure_web_bind,
|
||||||
|
mock_ensure_container_name,
|
||||||
|
mock_backend_network,
|
||||||
mock_connect_network,
|
mock_connect_network,
|
||||||
mock_get_container_name,
|
|
||||||
mock_get_container_id,
|
mock_get_container_id,
|
||||||
mock_execute_compose,
|
mock_execute_compose,
|
||||||
mock_wait_container,
|
mock_wait_container,
|
||||||
@@ -438,9 +452,12 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_get_project.return_value = AsyncMock()
|
mock_get_project.return_value = AsyncMock()
|
||||||
mock_execute_compose.return_value = (0, "started", "")
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
mock_get_container_id.return_value = "abc123"
|
mock_get_container_id.return_value = "abc123"
|
||||||
mock_get_container_name.return_value = "test-container"
|
|
||||||
mock_connect_network.return_value = True
|
mock_connect_network.return_value = True
|
||||||
mock_wait_container.return_value = {"success": True, "status": "running", "waited_seconds": 0.5}
|
mock_wait_container.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"status": "running",
|
||||||
|
"waited_seconds": 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
instance = ToolInstance(
|
instance = ToolInstance(
|
||||||
id=fake_instance_id,
|
id=fake_instance_id,
|
||||||
@@ -503,8 +520,10 @@ class TestStartInstanceLegacyFallback:
|
|||||||
@patch("src.api.tool_instances.wait_for_container_running")
|
@patch("src.api.tool_instances.wait_for_container_running")
|
||||||
@patch("src.api.tool_instances.execute_compose_command")
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
@patch("src.api.tool_instances.get_container_id")
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
@patch("src.api.tool_instances.get_container_name")
|
|
||||||
@patch("src.api.tool_instances.connect_container_to_network")
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
|
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||||
@patch("src.api.tool_instances._get_user")
|
@patch("src.api.tool_instances._get_user")
|
||||||
@@ -515,8 +534,10 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_get_user,
|
mock_get_user,
|
||||||
mock_prepare_manifest,
|
mock_prepare_manifest,
|
||||||
mock_sanitize,
|
mock_sanitize,
|
||||||
|
mock_ensure_web_bind,
|
||||||
|
mock_ensure_container_name,
|
||||||
|
mock_backend_network,
|
||||||
mock_connect_network,
|
mock_connect_network,
|
||||||
mock_get_container_name,
|
|
||||||
mock_get_container_id,
|
mock_get_container_id,
|
||||||
mock_execute_compose,
|
mock_execute_compose,
|
||||||
mock_wait_container,
|
mock_wait_container,
|
||||||
@@ -532,9 +553,12 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_get_project.return_value = AsyncMock()
|
mock_get_project.return_value = AsyncMock()
|
||||||
mock_execute_compose.return_value = (0, "started", "")
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
mock_get_container_id.return_value = "abc123"
|
mock_get_container_id.return_value = "abc123"
|
||||||
mock_get_container_name.return_value = "test-container"
|
|
||||||
mock_connect_network.return_value = True
|
mock_connect_network.return_value = True
|
||||||
mock_wait_container.return_value = {"success": True, "status": "running", "waited_seconds": 0.5}
|
mock_wait_container.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"status": "running",
|
||||||
|
"waited_seconds": 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
instance = ToolInstance(
|
instance = ToolInstance(
|
||||||
id=fake_instance_id,
|
id=fake_instance_id,
|
||||||
@@ -596,8 +620,10 @@ class TestStartInstanceLegacyFallback:
|
|||||||
@patch("src.api.tool_instances.wait_for_container_running")
|
@patch("src.api.tool_instances.wait_for_container_running")
|
||||||
@patch("src.api.tool_instances.execute_compose_command")
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
@patch("src.api.tool_instances.get_container_id")
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
@patch("src.api.tool_instances.get_container_name")
|
|
||||||
@patch("src.api.tool_instances.connect_container_to_network")
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
|
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||||
@patch("src.api.tool_instances._get_user")
|
@patch("src.api.tool_instances._get_user")
|
||||||
@@ -608,8 +634,10 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_get_user,
|
mock_get_user,
|
||||||
mock_prepare_manifest,
|
mock_prepare_manifest,
|
||||||
mock_sanitize,
|
mock_sanitize,
|
||||||
|
mock_ensure_web_bind,
|
||||||
|
mock_ensure_container_name,
|
||||||
|
mock_backend_network,
|
||||||
mock_connect_network,
|
mock_connect_network,
|
||||||
mock_get_container_name,
|
|
||||||
mock_get_container_id,
|
mock_get_container_id,
|
||||||
mock_execute_compose,
|
mock_execute_compose,
|
||||||
mock_wait_container,
|
mock_wait_container,
|
||||||
@@ -625,9 +653,12 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_get_project.return_value = AsyncMock()
|
mock_get_project.return_value = AsyncMock()
|
||||||
mock_execute_compose.return_value = (0, "started", "")
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
mock_get_container_id.return_value = "abc123"
|
mock_get_container_id.return_value = "abc123"
|
||||||
mock_get_container_name.return_value = "test-container"
|
|
||||||
mock_connect_network.return_value = True
|
mock_connect_network.return_value = True
|
||||||
mock_wait_container.return_value = {"success": True, "status": "running", "waited_seconds": 0.5}
|
mock_wait_container.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"status": "running",
|
||||||
|
"waited_seconds": 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
instance = ToolInstance(
|
instance = ToolInstance(
|
||||||
id=fake_instance_id,
|
id=fake_instance_id,
|
||||||
@@ -687,14 +718,274 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_execute_compose.assert_called_once()
|
mock_execute_compose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartInstanceSshPermissions:
|
||||||
|
"""SSH key mounts trigger permission fixes after container starts."""
|
||||||
|
|
||||||
|
@patch("src.api.tool_instances.write_compose_file")
|
||||||
|
@patch("src.api.tool_instances.prepare_ssh_key_files")
|
||||||
|
@patch("src.api.tool_instances.apply_ssh_permissions")
|
||||||
|
@patch("src.api.tool_instances.wait_for_container_running")
|
||||||
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
|
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
|
@patch("src.api.tool_instances._get_user")
|
||||||
|
@patch("src.api.tool_instances._get_owned_project")
|
||||||
|
async def test_manifest_instance_applies_ssh_permissions(
|
||||||
|
self,
|
||||||
|
mock_get_project,
|
||||||
|
mock_get_user,
|
||||||
|
mock_sanitize,
|
||||||
|
mock_ensure_web_bind,
|
||||||
|
mock_ensure_container_name,
|
||||||
|
mock_backend_network,
|
||||||
|
mock_connect_network,
|
||||||
|
mock_get_container_id,
|
||||||
|
mock_execute_compose,
|
||||||
|
mock_wait_container,
|
||||||
|
mock_apply_ssh,
|
||||||
|
mock_prepare_ssh,
|
||||||
|
mock_write_compose,
|
||||||
|
mock_session,
|
||||||
|
fake_user_id,
|
||||||
|
fake_project_id,
|
||||||
|
fake_repo_id,
|
||||||
|
fake_instance_id,
|
||||||
|
fake_tool_type_id,
|
||||||
|
) -> None:
|
||||||
|
"""Manifest instance with SSH keys calls apply_ssh_permissions."""
|
||||||
|
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||||
|
|
||||||
|
manifest_id = uuid.uuid4()
|
||||||
|
ssh_key_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
mock_get_user.return_value = AsyncMock()
|
||||||
|
mock_get_project.return_value = AsyncMock()
|
||||||
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
|
mock_get_container_id.return_value = "abc123"
|
||||||
|
mock_connect_network.return_value = True
|
||||||
|
mock_wait_container.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"status": "running",
|
||||||
|
"waited_seconds": 0.5,
|
||||||
|
}
|
||||||
|
mock_apply_ssh.return_value = {"success": True, "error": None}
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=fake_instance_id,
|
||||||
|
name="manifest-instance",
|
||||||
|
repository_id=fake_repo_id,
|
||||||
|
tool_type_id=fake_tool_type_id,
|
||||||
|
compose_path="/data/instances/manifest-instance/docker-compose.yml",
|
||||||
|
status="stopped",
|
||||||
|
clone_mode="mount",
|
||||||
|
ssh_key_ids=[ssh_key_id],
|
||||||
|
created_at=datetime.now(),
|
||||||
|
updated_at=datetime.now(),
|
||||||
|
)
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=fake_tool_type_id,
|
||||||
|
name="manifest-tool",
|
||||||
|
display_name="Manifest Tool",
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="manifest",
|
||||||
|
manifest_id=manifest_id,
|
||||||
|
dockerfile_template=None,
|
||||||
|
compose_template=None,
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=fake_repo_id,
|
||||||
|
project_id=fake_project_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/data/repos/test-repo",
|
||||||
|
remote_url=None,
|
||||||
|
ssh_key_id=None,
|
||||||
|
)
|
||||||
|
manifest_def = ToolDefinitionManifest(
|
||||||
|
id=manifest_id,
|
||||||
|
name="test-manifest",
|
||||||
|
display_name="Test Manifest",
|
||||||
|
interface_type="web",
|
||||||
|
manifest={"user": {"name": "user", "uid": 1001, "gid": 1001}},
|
||||||
|
)
|
||||||
|
ssh_key = SSHKey(
|
||||||
|
id=uuid.UUID(ssh_key_id),
|
||||||
|
user_id=fake_user_id,
|
||||||
|
name="test-key",
|
||||||
|
public_key="ssh-ed25519 AAA test@test",
|
||||||
|
private_key_encrypted="enc",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get(model, pk):
|
||||||
|
if model is ToolInstance and pk == fake_instance_id:
|
||||||
|
return instance
|
||||||
|
if model is ToolType and pk == fake_tool_type_id:
|
||||||
|
return tool_type
|
||||||
|
if model is GitRepository and pk == fake_repo_id:
|
||||||
|
return repo
|
||||||
|
if model is User and pk == fake_user_id:
|
||||||
|
return User(id=fake_user_id, email="test@example.com")
|
||||||
|
if model is ToolDefinitionManifest and pk == manifest_id:
|
||||||
|
return manifest_def
|
||||||
|
if model is SSHKey and pk == uuid.UUID(ssh_key_id):
|
||||||
|
return ssh_key
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_session.get.side_effect = _get
|
||||||
|
|
||||||
|
with patch("os.path.exists", return_value=True):
|
||||||
|
with patch("os.makedirs"):
|
||||||
|
with patch(
|
||||||
|
"src.api.tool_instances._prepare_manifest_instance"
|
||||||
|
) as mock_prepare:
|
||||||
|
mock_prepare.return_value = (
|
||||||
|
"headquarter/test:latest",
|
||||||
|
"services:\n app:\n image: test",
|
||||||
|
{"name": "test-manifest", "user": {"name": "user"}},
|
||||||
|
"/home/user",
|
||||||
|
)
|
||||||
|
result = await start_instance(
|
||||||
|
project_id=fake_project_id,
|
||||||
|
repo_id=fake_repo_id,
|
||||||
|
instance_id=fake_instance_id,
|
||||||
|
data=None,
|
||||||
|
user_id=fake_user_id,
|
||||||
|
session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "running"
|
||||||
|
mock_apply_ssh.assert_called_once_with("abc123", "/home/user/.ssh", "user")
|
||||||
|
|
||||||
|
@patch("src.api.tool_instances.prepare_ssh_key_files")
|
||||||
|
@patch("src.api.tool_instances.apply_ssh_permissions")
|
||||||
|
@patch("src.api.tool_instances.wait_for_container_running")
|
||||||
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
|
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
|
@patch("src.api.tool_instances._get_user")
|
||||||
|
@patch("src.api.tool_instances._get_owned_project")
|
||||||
|
async def test_legacy_instance_applies_ssh_permissions(
|
||||||
|
self,
|
||||||
|
mock_get_project,
|
||||||
|
mock_get_user,
|
||||||
|
mock_sanitize,
|
||||||
|
mock_ensure_web_bind,
|
||||||
|
mock_ensure_container_name,
|
||||||
|
mock_backend_network,
|
||||||
|
mock_connect_network,
|
||||||
|
mock_get_container_id,
|
||||||
|
mock_execute_compose,
|
||||||
|
mock_wait_container,
|
||||||
|
mock_apply_ssh,
|
||||||
|
mock_prepare_ssh,
|
||||||
|
mock_session,
|
||||||
|
fake_user_id,
|
||||||
|
fake_project_id,
|
||||||
|
fake_repo_id,
|
||||||
|
fake_instance_id,
|
||||||
|
fake_tool_type_id,
|
||||||
|
) -> None:
|
||||||
|
"""Legacy instance with SSH keys calls apply_ssh_permissions."""
|
||||||
|
ssh_key_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
mock_get_user.return_value = AsyncMock()
|
||||||
|
mock_get_project.return_value = AsyncMock()
|
||||||
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
|
mock_get_container_id.return_value = "abc123"
|
||||||
|
mock_connect_network.return_value = True
|
||||||
|
mock_wait_container.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"status": "running",
|
||||||
|
"waited_seconds": 0.5,
|
||||||
|
}
|
||||||
|
mock_apply_ssh.return_value = {"success": True, "error": None}
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=fake_instance_id,
|
||||||
|
name="legacy-instance",
|
||||||
|
repository_id=fake_repo_id,
|
||||||
|
tool_type_id=fake_tool_type_id,
|
||||||
|
compose_path="/data/instances/legacy-instance/docker-compose.yml",
|
||||||
|
status="stopped",
|
||||||
|
clone_mode="mount",
|
||||||
|
ssh_key_ids=[ssh_key_id],
|
||||||
|
created_at=datetime.now(),
|
||||||
|
updated_at=datetime.now(),
|
||||||
|
)
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=fake_tool_type_id,
|
||||||
|
name="legacy-tool",
|
||||||
|
display_name="Legacy Tool",
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="legacy",
|
||||||
|
manifest_id=None,
|
||||||
|
dockerfile_template=None,
|
||||||
|
compose_template="services:\n app:\n image: nginx",
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=fake_repo_id,
|
||||||
|
project_id=fake_project_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/data/repos/test-repo",
|
||||||
|
remote_url=None,
|
||||||
|
ssh_key_id=None,
|
||||||
|
)
|
||||||
|
ssh_key = SSHKey(
|
||||||
|
id=uuid.UUID(ssh_key_id),
|
||||||
|
user_id=fake_user_id,
|
||||||
|
name="test-key",
|
||||||
|
public_key="ssh-ed25519 AAA test@test",
|
||||||
|
private_key_encrypted="enc",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get(model, pk):
|
||||||
|
if model is ToolInstance and pk == fake_instance_id:
|
||||||
|
return instance
|
||||||
|
if model is ToolType and pk == fake_tool_type_id:
|
||||||
|
return tool_type
|
||||||
|
if model is GitRepository and pk == fake_repo_id:
|
||||||
|
return repo
|
||||||
|
if model is User and pk == fake_user_id:
|
||||||
|
return User(id=fake_user_id, email="test@example.com")
|
||||||
|
if model is SSHKey and pk == uuid.UUID(ssh_key_id):
|
||||||
|
return ssh_key
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_session.get.side_effect = _get
|
||||||
|
|
||||||
|
with patch("os.path.exists", return_value=True):
|
||||||
|
with patch("os.makedirs"):
|
||||||
|
with patch("src.api.tool_instances._modify_compose_file"):
|
||||||
|
result = await start_instance(
|
||||||
|
project_id=fake_project_id,
|
||||||
|
repo_id=fake_repo_id,
|
||||||
|
instance_id=fake_instance_id,
|
||||||
|
data=None,
|
||||||
|
user_id=fake_user_id,
|
||||||
|
session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "running"
|
||||||
|
mock_apply_ssh.assert_called_once_with("abc123", "/root/.ssh", "root")
|
||||||
|
|
||||||
|
|
||||||
class TestStartInstanceManifestBranch:
|
class TestStartInstanceManifestBranch:
|
||||||
"""Manifest branch is taken ONLY when definition_type == 'manifest'."""
|
"""Manifest branch is taken ONLY when definition_type == 'manifest'."""
|
||||||
|
|
||||||
@patch("src.api.tool_instances.wait_for_container_running")
|
@patch("src.api.tool_instances.wait_for_container_running")
|
||||||
@patch("src.api.tool_instances.execute_compose_command")
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
@patch("src.api.tool_instances.get_container_id")
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
@patch("src.api.tool_instances.get_container_name")
|
|
||||||
@patch("src.api.tool_instances.connect_container_to_network")
|
@patch("src.api.tool_instances.connect_container_to_network")
|
||||||
|
@patch("src.api.tool_instances._ensure_backend_network_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_container_name_in_compose")
|
||||||
|
@patch("src.api.tool_instances._ensure_web_bind_address")
|
||||||
@patch("src.api.tool_instances._sanitize_compose_file")
|
@patch("src.api.tool_instances._sanitize_compose_file")
|
||||||
@patch("src.api.tool_instances._prepare_manifest_instance")
|
@patch("src.api.tool_instances._prepare_manifest_instance")
|
||||||
@patch("src.api.tool_instances.write_compose_file")
|
@patch("src.api.tool_instances.write_compose_file")
|
||||||
@@ -707,8 +998,10 @@ class TestStartInstanceManifestBranch:
|
|||||||
mock_write_compose,
|
mock_write_compose,
|
||||||
mock_prepare_manifest,
|
mock_prepare_manifest,
|
||||||
mock_sanitize,
|
mock_sanitize,
|
||||||
|
mock_ensure_web_bind,
|
||||||
|
mock_ensure_container_name,
|
||||||
|
mock_backend_network,
|
||||||
mock_connect_network,
|
mock_connect_network,
|
||||||
mock_get_container_name,
|
|
||||||
mock_get_container_id,
|
mock_get_container_id,
|
||||||
mock_execute_compose,
|
mock_execute_compose,
|
||||||
mock_wait_container,
|
mock_wait_container,
|
||||||
@@ -728,13 +1021,17 @@ class TestStartInstanceManifestBranch:
|
|||||||
mock_get_project.return_value = AsyncMock()
|
mock_get_project.return_value = AsyncMock()
|
||||||
mock_execute_compose.return_value = (0, "started", "")
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
mock_get_container_id.return_value = "abc123"
|
mock_get_container_id.return_value = "abc123"
|
||||||
mock_get_container_name.return_value = "test-container"
|
|
||||||
mock_connect_network.return_value = True
|
mock_connect_network.return_value = True
|
||||||
mock_wait_container.return_value = {"success": True, "status": "running", "waited_seconds": 0.5}
|
mock_wait_container.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"status": "running",
|
||||||
|
"waited_seconds": 0.5,
|
||||||
|
}
|
||||||
mock_prepare_manifest.return_value = (
|
mock_prepare_manifest.return_value = (
|
||||||
"headquarter/test:latest",
|
"headquarter/test:latest",
|
||||||
"services:\n app:\n image: test",
|
"services:\n app:\n image: test",
|
||||||
{"name": "test-manifest"},
|
{"name": "test-manifest"},
|
||||||
|
"/root",
|
||||||
)
|
)
|
||||||
|
|
||||||
instance = ToolInstance(
|
instance = ToolInstance(
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Never cache index.html so browsers always fetch new hashed JS/CSS
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Pragma "no-cache";
|
||||||
|
}
|
||||||
|
|
||||||
# Cache static assets
|
# Cache static assets
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
|
|||||||
Generated
+12
-513
@@ -19,7 +19,8 @@
|
|||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
"xterm-addon-fit": "^0.8.0",
|
"xterm-addon-fit": "^0.8.0",
|
||||||
"xterm-addon-web-links": "^0.9.0"
|
"xterm-addon-web-links": "^0.9.0",
|
||||||
|
"xterm-addon-webgl": "^0.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -896,24 +897,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||||
@@ -931,24 +914,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||||
@@ -966,24 +931,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openharmony"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||||
@@ -6107,420 +6054,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@vitest/mocker": {
|
"node_modules/vitest/node_modules/@vitest/mocker": {
|
||||||
"version": "4.1.6",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
|
||||||
@@ -6548,50 +6081,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest/node_modules/esbuild": {
|
|
||||||
"version": "0.28.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
|
||||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.28.0",
|
|
||||||
"@esbuild/android-arm": "0.28.0",
|
|
||||||
"@esbuild/android-arm64": "0.28.0",
|
|
||||||
"@esbuild/android-x64": "0.28.0",
|
|
||||||
"@esbuild/darwin-arm64": "0.28.0",
|
|
||||||
"@esbuild/darwin-x64": "0.28.0",
|
|
||||||
"@esbuild/freebsd-arm64": "0.28.0",
|
|
||||||
"@esbuild/freebsd-x64": "0.28.0",
|
|
||||||
"@esbuild/linux-arm": "0.28.0",
|
|
||||||
"@esbuild/linux-arm64": "0.28.0",
|
|
||||||
"@esbuild/linux-ia32": "0.28.0",
|
|
||||||
"@esbuild/linux-loong64": "0.28.0",
|
|
||||||
"@esbuild/linux-mips64el": "0.28.0",
|
|
||||||
"@esbuild/linux-ppc64": "0.28.0",
|
|
||||||
"@esbuild/linux-riscv64": "0.28.0",
|
|
||||||
"@esbuild/linux-s390x": "0.28.0",
|
|
||||||
"@esbuild/linux-x64": "0.28.0",
|
|
||||||
"@esbuild/netbsd-arm64": "0.28.0",
|
|
||||||
"@esbuild/netbsd-x64": "0.28.0",
|
|
||||||
"@esbuild/openbsd-arm64": "0.28.0",
|
|
||||||
"@esbuild/openbsd-x64": "0.28.0",
|
|
||||||
"@esbuild/openharmony-arm64": "0.28.0",
|
|
||||||
"@esbuild/sunos-x64": "0.28.0",
|
|
||||||
"@esbuild/win32-arm64": "0.28.0",
|
|
||||||
"@esbuild/win32-ia32": "0.28.0",
|
|
||||||
"@esbuild/win32-x64": "0.28.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/picomatch": {
|
"node_modules/vitest/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
@@ -6825,6 +6314,16 @@
|
|||||||
"xterm": "^5.0.0"
|
"xterm": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xterm-addon-webgl": {
|
||||||
|
"version": "0.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xterm-addon-webgl/-/xterm-addon-webgl-0.16.0.tgz",
|
||||||
|
"integrity": "sha512-E8cq1AiqNOv0M/FghPT+zPAEnvIQRDbAbkb04rRYSxUym69elPWVJ4sv22FCLBqM/3LcrmBLl/pELnBebVFKgA==",
|
||||||
|
"deprecated": "This package is now deprecated. Move to @xterm/addon-webgl instead.",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"xterm": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
"xterm-addon-fit": "^0.8.0",
|
"xterm-addon-fit": "^0.8.0",
|
||||||
"xterm-addon-web-links": "^0.9.0"
|
"xterm-addon-web-links": "^0.9.0",
|
||||||
|
"xterm-addon-webgl": "^0.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
|||||||
@@ -24,11 +24,18 @@ export interface ConfigProfileMount {
|
|||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitMount {
|
export interface GitMountMapping {
|
||||||
remote_url: string;
|
|
||||||
source_path: string;
|
source_path: string;
|
||||||
target_path: string;
|
target_path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitMount {
|
||||||
|
remote_url: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
|
mappings: GitMountMapping[];
|
||||||
|
// Legacy fields (for backward compatibility when reading old data)
|
||||||
|
source_path?: string;
|
||||||
|
target_path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigProfileInclude {
|
export interface ConfigProfileInclude {
|
||||||
@@ -93,7 +100,7 @@ export interface UpdateIncludesRequest {
|
|||||||
|
|
||||||
export const listConfigProfiles = async (
|
export const listConfigProfiles = async (
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
toolTypeId?: string
|
toolTypeId?: string,
|
||||||
): Promise<ConfigProfile[]> => {
|
): Promise<ConfigProfile[]> => {
|
||||||
const response = await apiClient.get<ConfigProfile[]>("/config-profiles", {
|
const response = await apiClient.get<ConfigProfile[]>("/config-profiles", {
|
||||||
params: { project_id: projectId, tool_type_id: toolTypeId },
|
params: { project_id: projectId, tool_type_id: toolTypeId },
|
||||||
@@ -107,17 +114,23 @@ export const getConfigProfile = async (id: string): Promise<ConfigProfile> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createConfigProfile = async (
|
export const createConfigProfile = async (
|
||||||
data: CreateConfigProfileRequest
|
data: CreateConfigProfileRequest,
|
||||||
): Promise<ConfigProfile> => {
|
): Promise<ConfigProfile> => {
|
||||||
const response = await apiClient.post<ConfigProfile>("/config-profiles", data);
|
const response = await apiClient.post<ConfigProfile>(
|
||||||
|
"/config-profiles",
|
||||||
|
data,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateConfigProfile = async (
|
export const updateConfigProfile = async (
|
||||||
id: string,
|
id: string,
|
||||||
data: UpdateConfigProfileRequest
|
data: UpdateConfigProfileRequest,
|
||||||
): Promise<ConfigProfile> => {
|
): Promise<ConfigProfile> => {
|
||||||
const response = await apiClient.put<ConfigProfile>(`/config-profiles/${id}`, data);
|
const response = await apiClient.put<ConfigProfile>(
|
||||||
|
`/config-profiles/${id}`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,30 +140,50 @@ export const deleteConfigProfile = async (id: string): Promise<void> => {
|
|||||||
|
|
||||||
export const updateProfileIncludes = async (
|
export const updateProfileIncludes = async (
|
||||||
id: string,
|
id: string,
|
||||||
data: UpdateIncludesRequest
|
data: UpdateIncludesRequest,
|
||||||
): Promise<ConfigProfile> => {
|
): Promise<ConfigProfile> => {
|
||||||
const response = await apiClient.put<ConfigProfile>(
|
const response = await apiClient.put<ConfigProfile>(
|
||||||
`/config-profiles/${id}/includes`,
|
`/config-profiles/${id}/includes`,
|
||||||
data
|
data,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const previewConfigProfile = async (
|
export const previewConfigProfile = async (
|
||||||
id: string
|
id: string,
|
||||||
): Promise<ResolvedProfile> => {
|
): Promise<ResolvedProfile> => {
|
||||||
const response = await apiClient.get<ResolvedProfile>(
|
const response = await apiClient.get<ResolvedProfile>(
|
||||||
`/config-profiles/${id}/preview`
|
`/config-profiles/${id}/preview`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveDefaultProfile = async (
|
export const resolveDefaultProfile = async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
toolTypeId: string
|
toolTypeId: string,
|
||||||
): Promise<{ profile_id: string | null; profile_name: string | null }> => {
|
): Promise<{ profile_id: string | null; profile_name: string | null }> => {
|
||||||
const response = await apiClient.get("/config-profiles/defaults/resolve", {
|
const response = await apiClient.get("/config-profiles/defaults/resolve", {
|
||||||
params: { project_id: projectId, tool_type_id: toolTypeId },
|
params: { project_id: projectId, tool_type_id: toolTypeId },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ValidateGitUrlResponse {
|
||||||
|
valid: boolean;
|
||||||
|
suggested_url?: string;
|
||||||
|
branches?: string[];
|
||||||
|
default_branch?: string;
|
||||||
|
error?: string;
|
||||||
|
error_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateGitUrl = async (
|
||||||
|
url: string,
|
||||||
|
sshKeyId?: string,
|
||||||
|
): Promise<ValidateGitUrlResponse> => {
|
||||||
|
const response = await apiClient.post<ValidateGitUrlResponse>(
|
||||||
|
"/config-profiles/validate-git-url",
|
||||||
|
{ url, ssh_key_id: sshKeyId },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
|
export function createEventSource(): EventSource {
|
||||||
|
return new EventSource(`${BASE_URL}/events/stream`, {
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeEventStreamStatus(): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 2000);
|
||||||
|
const res = await fetch(`${BASE_URL}/events/stream`, {
|
||||||
|
credentials: "include",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timer);
|
||||||
|
return res.status;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface NotificationItem {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
category: string;
|
||||||
|
severity: "info" | "warning" | "error" | "success";
|
||||||
|
title: string;
|
||||||
|
message: string | null;
|
||||||
|
source_type: string | null;
|
||||||
|
source_id: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
read_at: string | null;
|
||||||
|
dismissed_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationListResponse {
|
||||||
|
items: NotificationItem[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnreadCountResponse {
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkAllReadResponse {
|
||||||
|
marked_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClearAllResponse {
|
||||||
|
cleared_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getNotifications = async (): Promise<NotificationListResponse> => {
|
||||||
|
const response =
|
||||||
|
await apiClient.get<NotificationListResponse>("/notifications");
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUnreadCount = async (): Promise<number> => {
|
||||||
|
const response = await apiClient.get<UnreadCountResponse>(
|
||||||
|
"/notifications/unread",
|
||||||
|
);
|
||||||
|
return response.data.count;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markNotificationRead = async (
|
||||||
|
id: string,
|
||||||
|
): Promise<NotificationItem> => {
|
||||||
|
const response = await apiClient.patch<NotificationItem>(
|
||||||
|
`/notifications/${id}/read`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markAllNotificationsRead = async (): Promise<number> => {
|
||||||
|
const response = await apiClient.post<MarkAllReadResponse>(
|
||||||
|
"/notifications/mark-all-read",
|
||||||
|
);
|
||||||
|
return response.data.marked_count;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dismissNotification = async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/notifications/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAllNotifications = async (): Promise<number> => {
|
||||||
|
const response = await apiClient.delete<ClearAllResponse>("/notifications");
|
||||||
|
return response.data.cleared_count;
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
import type { Project } from "../types";
|
import type { Project, ProjectWithRepos } from "../types";
|
||||||
|
|
||||||
export type ProjectCreateInput = {
|
export type ProjectCreateInput = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -15,13 +15,13 @@ export type SetDefaultSSHKeyInput = {
|
|||||||
ssh_key_id: string;
|
ssh_key_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const listProjects = async (): Promise<Project[]> => {
|
export const listProjects = async (): Promise<ProjectWithRepos[]> => {
|
||||||
const response = await apiClient.get<Project[]>("/projects");
|
const response = await apiClient.get<ProjectWithRepos[]>("/projects");
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createProject = async (
|
export const createProject = async (
|
||||||
input: ProjectCreateInput
|
input: ProjectCreateInput,
|
||||||
): Promise<Project> => {
|
): Promise<Project> => {
|
||||||
const response = await apiClient.post<Project>("/projects", input);
|
const response = await apiClient.post<Project>("/projects", input);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -29,9 +29,12 @@ export const createProject = async (
|
|||||||
|
|
||||||
export const updateProject = async (
|
export const updateProject = async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
input: ProjectUpdateInput
|
input: ProjectUpdateInput,
|
||||||
): Promise<Project> => {
|
): Promise<Project> => {
|
||||||
const response = await apiClient.patch<Project>(`/projects/${projectId}`, input);
|
const response = await apiClient.patch<Project>(
|
||||||
|
`/projects/${projectId}`,
|
||||||
|
input,
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,11 +44,11 @@ export const deleteProject = async (projectId: string): Promise<void> => {
|
|||||||
|
|
||||||
export const setDefaultSSHKey = async (
|
export const setDefaultSSHKey = async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
input: SetDefaultSSHKeyInput
|
input: SetDefaultSSHKeyInput,
|
||||||
): Promise<Project> => {
|
): Promise<Project> => {
|
||||||
const response = await apiClient.patch<Project>(
|
const response = await apiClient.patch<Project>(
|
||||||
`/projects/${projectId}/default-ssh-key`,
|
`/projects/${projectId}/default-ssh-key`,
|
||||||
input
|
input,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface ToolInstance {
|
|||||||
url: string | null;
|
url: string | null;
|
||||||
port: number | null;
|
port: number | null;
|
||||||
selected_config_profile_id: string | null;
|
selected_config_profile_id: string | null;
|
||||||
|
ssh_key_ids: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,10 +37,10 @@ export interface Session {
|
|||||||
|
|
||||||
export async function listInstances(
|
export async function listInstances(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string
|
repoId: string,
|
||||||
): Promise<ToolInstance[]> {
|
): Promise<ToolInstance[]> {
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances`
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
);
|
);
|
||||||
return response.data.instances;
|
return response.data.instances;
|
||||||
}
|
}
|
||||||
@@ -52,18 +53,22 @@ export async function createInstance(
|
|||||||
cloneMode?: string,
|
cloneMode?: string,
|
||||||
branch?: string,
|
branch?: string,
|
||||||
newBranch?: string,
|
newBranch?: string,
|
||||||
configProfileId?: string
|
configProfileId?: string,
|
||||||
|
sshKeyIds?: string[],
|
||||||
|
workspaceId?: string,
|
||||||
): Promise<ToolInstance> {
|
): Promise<ToolInstance> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
{
|
{
|
||||||
tool_type_id: toolTypeId,
|
tool_type_id: toolTypeId,
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
|
workspace_id: workspaceId || undefined,
|
||||||
clone_mode: cloneMode || "mount",
|
clone_mode: cloneMode || "mount",
|
||||||
branch: branch || undefined,
|
branch: branch || undefined,
|
||||||
new_branch: newBranch || undefined,
|
new_branch: newBranch || undefined,
|
||||||
config_profile_id: configProfileId,
|
config_profile_id: configProfileId,
|
||||||
}
|
ssh_key_ids: sshKeyIds || [],
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -73,12 +78,13 @@ export async function startInstance(
|
|||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
configProfileId?: string,
|
configProfileId?: string,
|
||||||
retries = 2
|
sshKeyIds?: string[],
|
||||||
|
retries = 2,
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
||||||
{ config_profile_id: configProfileId }
|
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] },
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -86,7 +92,14 @@ export async function startInstance(
|
|||||||
const axiosError = error as AxiosError;
|
const axiosError = error as AxiosError;
|
||||||
if (retries > 0 && !axiosError.response) {
|
if (retries > 0 && !axiosError.response) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
return startInstance(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
instanceId,
|
||||||
|
configProfileId,
|
||||||
|
sshKeyIds,
|
||||||
|
retries - 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -95,10 +108,10 @@ export async function startInstance(
|
|||||||
export async function stopInstance(
|
export async function stopInstance(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
): Promise<{ status: string }> {
|
): Promise<{ status: string }> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -108,12 +121,13 @@ export async function restartInstance(
|
|||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
configProfileId?: string,
|
configProfileId?: string,
|
||||||
retries = 2
|
sshKeyIds?: string[],
|
||||||
|
retries = 2,
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
||||||
{ config_profile_id: configProfileId }
|
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] },
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -121,7 +135,14 @@ export async function restartInstance(
|
|||||||
const axiosError = error as AxiosError;
|
const axiosError = error as AxiosError;
|
||||||
if (retries > 0 && !axiosError.response) {
|
if (retries > 0 && !axiosError.response) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
return restartInstance(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
instanceId,
|
||||||
|
configProfileId,
|
||||||
|
sshKeyIds,
|
||||||
|
retries - 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -131,11 +152,11 @@ export async function deleteInstance(
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
force?: boolean
|
force?: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await apiClient.delete(
|
await apiClient.delete(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
||||||
{ params: { force } }
|
{ params: { force } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,10 +180,10 @@ export interface InstanceHealth {
|
|||||||
export async function checkInstanceHealth(
|
export async function checkInstanceHealth(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
): Promise<InstanceHealth> {
|
): Promise<InstanceHealth> {
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@@ -170,10 +191,10 @@ export async function checkInstanceHealth(
|
|||||||
export async function recreateInstanceTunnel(
|
export async function recreateInstanceTunnel(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string
|
instanceId: string,
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface UserConfig {
|
|||||||
git_user_name: string | null;
|
git_user_name: string | null;
|
||||||
git_user_email: string | null;
|
git_user_email: string | null;
|
||||||
last_session_id: string | null;
|
last_session_id: string | null;
|
||||||
|
notification_toast_level?: "all" | "errors" | "none";
|
||||||
|
notification_mute_categories?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserConfigUpdate {
|
export interface UserConfigUpdate {
|
||||||
@@ -14,6 +16,8 @@ export interface UserConfigUpdate {
|
|||||||
git_user_name?: string | null;
|
git_user_name?: string | null;
|
||||||
git_user_email?: string | null;
|
git_user_email?: string | null;
|
||||||
last_session_id?: string | null;
|
last_session_id?: string | null;
|
||||||
|
notification_toast_level?: "all" | "errors" | "none";
|
||||||
|
notification_mute_categories?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getUserConfig = async (): Promise<UserConfig> => {
|
export const getUserConfig = async (): Promise<UserConfig> => {
|
||||||
@@ -21,7 +25,9 @@ export const getUserConfig = async (): Promise<UserConfig> => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateUserConfig = async (data: UserConfigUpdate): Promise<UserConfig> => {
|
export const updateUserConfig = async (
|
||||||
|
data: UserConfigUpdate,
|
||||||
|
): Promise<UserConfig> => {
|
||||||
const response = await apiClient.patch<UserConfig>("/users/me/config", data);
|
const response = await apiClient.patch<UserConfig>("/users/me/config", data);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/** Workspace file API client. */
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: "file" | "directory";
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkspaceFiles(
|
||||||
|
workspaceId: string,
|
||||||
|
path: string = "",
|
||||||
|
): Promise<FileEntry[]> {
|
||||||
|
const response = await apiClient.get<{ entries: FileEntry[] }>(
|
||||||
|
`/workspaces/${workspaceId}/files/`,
|
||||||
|
{ params: { path } },
|
||||||
|
);
|
||||||
|
return response.data.entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkspaceFileContent(
|
||||||
|
workspaceId: string,
|
||||||
|
path: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await apiClient.get<{ content: string }>(
|
||||||
|
`/workspaces/${workspaceId}/files/content`,
|
||||||
|
{ params: { path } },
|
||||||
|
);
|
||||||
|
return response.data.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWorkspaceFile(
|
||||||
|
workspaceId: string,
|
||||||
|
path: string,
|
||||||
|
content: string,
|
||||||
|
commitMessage?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.post(`/workspaces/${workspaceId}/files/content`, {
|
||||||
|
path,
|
||||||
|
content,
|
||||||
|
message: commitMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/** Workspace git API client. */
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface GitStatus {
|
||||||
|
branch: string;
|
||||||
|
modified: string[];
|
||||||
|
added: string[];
|
||||||
|
deleted: string[];
|
||||||
|
untracked: string[];
|
||||||
|
ahead: number;
|
||||||
|
behind: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Commit {
|
||||||
|
hash: string;
|
||||||
|
message: string;
|
||||||
|
author: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGitStatus(workspaceId: string): Promise<GitStatus> {
|
||||||
|
const response = await apiClient.get<GitStatus>(
|
||||||
|
`/workspaces/${workspaceId}/git/status`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGitBranches(
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<{ branches: string[]; current_branch: string }> {
|
||||||
|
const response = await apiClient.get<{
|
||||||
|
branches: string[];
|
||||||
|
current_branch: string;
|
||||||
|
}>(`/workspaces/${workspaceId}/git/branches`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gitCommit(
|
||||||
|
workspaceId: string,
|
||||||
|
message: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.post(`/workspaces/${workspaceId}/git/commit`, { message });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gitPush(workspaceId: string): Promise<void> {
|
||||||
|
await apiClient.post(`/workspaces/${workspaceId}/git/push`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gitPull(workspaceId: string): Promise<void> {
|
||||||
|
await apiClient.post(`/workspaces/${workspaceId}/git/pull`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gitFetch(workspaceId: string): Promise<void> {
|
||||||
|
await apiClient.post(`/workspaces/${workspaceId}/git/fetch`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gitCheckout(
|
||||||
|
workspaceId: string,
|
||||||
|
branch: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.post(`/workspaces/${workspaceId}/git/checkout`, { branch });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGitHistory(
|
||||||
|
workspaceId: string,
|
||||||
|
path?: string,
|
||||||
|
limit: number = 50,
|
||||||
|
): Promise<Commit[]> {
|
||||||
|
const response = await apiClient.get<{ commits: Commit[] }>(
|
||||||
|
`/workspaces/${workspaceId}/git/history`,
|
||||||
|
{ params: { path, limit } },
|
||||||
|
);
|
||||||
|
return response.data.commits;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/** Workspace instance API client. */
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import type { ToolInstance } from "./sessions";
|
||||||
|
|
||||||
|
export async function listWorkspaceInstances(
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<ToolInstance[]> {
|
||||||
|
const response = await apiClient.get<ToolInstance[]>(
|
||||||
|
`/workspaces/${workspaceId}/instances/`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkspaceInstance(
|
||||||
|
workspaceId: string,
|
||||||
|
toolTypeId: string,
|
||||||
|
displayName?: string,
|
||||||
|
configProfileId?: string,
|
||||||
|
): Promise<ToolInstance> {
|
||||||
|
const response = await apiClient.post<ToolInstance>(
|
||||||
|
`/workspaces/${workspaceId}/instances/`,
|
||||||
|
{
|
||||||
|
tool_type_id: toolTypeId,
|
||||||
|
display_name: displayName,
|
||||||
|
config_profile_id: configProfileId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/** Workspace API client. */
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import type {
|
||||||
|
Workspace,
|
||||||
|
CreateWorkspaceRequest,
|
||||||
|
SyncResult,
|
||||||
|
} from "../types/workspace";
|
||||||
|
|
||||||
|
function workspaceUrl(projectId: string, repoId: string, workspaceId?: string) {
|
||||||
|
const base = `/projects/${projectId}/repositories/${repoId}/workspaces`;
|
||||||
|
return workspaceId ? `${base}/${workspaceId}` : `${base}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkspaces(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
): Promise<Workspace[]> {
|
||||||
|
const response = await apiClient.get<Workspace[]>(
|
||||||
|
workspaceUrl(projectId, repoId),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAllWorkspaces(): Promise<Workspace[]> {
|
||||||
|
const response = await apiClient.get<Workspace[]>("/workspaces/");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkspace(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
data: CreateWorkspaceRequest,
|
||||||
|
): Promise<Workspace> {
|
||||||
|
const response = await apiClient.post<Workspace>(
|
||||||
|
workspaceUrl(projectId, repoId),
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkspaceTopLevel(
|
||||||
|
data: CreateWorkspaceRequest & { repo_id: string },
|
||||||
|
): Promise<Workspace> {
|
||||||
|
const response = await apiClient.post<Workspace>("/workspaces/", data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkspace(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<Workspace> {
|
||||||
|
const response = await apiClient.get<Workspace>(
|
||||||
|
workspaceUrl(projectId, repoId, workspaceId),
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWorkspace(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
data: Partial<CreateWorkspaceRequest>,
|
||||||
|
): Promise<Workspace> {
|
||||||
|
const response = await apiClient.patch<Workspace>(
|
||||||
|
workspaceUrl(projectId, repoId, workspaceId),
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWorkspace(
|
||||||
|
workspaceId: string,
|
||||||
|
force = false,
|
||||||
|
): Promise<{ status: string }> {
|
||||||
|
const response = await apiClient.delete<{ status: string }>(
|
||||||
|
`/workspaces/${workspaceId}?force=${force}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncWorkspace(
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<SyncResult> {
|
||||||
|
const response = await apiClient.post<SyncResult>(
|
||||||
|
`${workspaceUrl(projectId, repoId, workspaceId)}/sync`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -7,27 +7,51 @@ import { useTheme } from "../hooks/use-theme";
|
|||||||
import { useAuth } from "../state/auth";
|
import { useAuth } from "../state/auth";
|
||||||
import { useSessions } from "../state/sessions";
|
import { useSessions } from "../state/sessions";
|
||||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
|
import { EventProvider } from "../state/events";
|
||||||
|
import { ToastProvider } from "../state/toast";
|
||||||
|
import { NotificationProvider } from "../state/notifications";
|
||||||
|
import { EventToastBridge } from "./event-toast-bridge";
|
||||||
|
import { NotificationCenter } from "./notification-center";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import { MobileNav } from "./mobile-nav";
|
import { MobileNav } from "./mobile-nav";
|
||||||
|
import { StartToolFAB } from "./start-tool-fab";
|
||||||
import type { IconName } from "../utils/icons";
|
import type { IconName } from "../utils/icons";
|
||||||
|
|
||||||
const NAV_ITEMS: { to: string; label: string; icon: IconName; badge?: "sessions" }[] = [
|
const NAV_ITEMS: {
|
||||||
|
to: string;
|
||||||
|
label: string;
|
||||||
|
icon: IconName;
|
||||||
|
badge?: "sessions";
|
||||||
|
}[] = [
|
||||||
{ to: "/", label: "Home", icon: "dashboard" },
|
{ to: "/", label: "Home", icon: "dashboard" },
|
||||||
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
|
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
|
||||||
|
{ to: "/workspaces", label: "Workspaces", icon: "folder" },
|
||||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||||
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||||
{ to: "/config-profiles", label: "Config Profiles", icon: "folder" },
|
{ to: "/config-profiles", label: "Config Profiles", icon: "folder" },
|
||||||
{ to: "/settings", label: "Settings", icon: "settings" }
|
{ to: "/settings", label: "Settings", icon: "settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SessionItem = ({ session }: { session: Session }) => {
|
const SessionItem = ({ session }: { session: Session }) => {
|
||||||
const isRunning = session.status === "running";
|
const isRunning = session.status === "running";
|
||||||
|
|
||||||
|
// Determine the link target:
|
||||||
|
// - Web tools open their tunnel URL
|
||||||
|
// - Terminal tools open the terminal page
|
||||||
|
// - Everything else falls back to the project page
|
||||||
|
const hasTerminal = session.tool_type_interfaces.includes("terminal");
|
||||||
|
const hasWeb = session.tool_type_interfaces.includes("web");
|
||||||
|
const href = session.url && hasWeb
|
||||||
|
? session.url
|
||||||
|
: hasTerminal
|
||||||
|
? `/instances/${session.id}/terminal`
|
||||||
|
: `/projects/${session.project_id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={session.url ?? `/projects/${session.project_id}`}
|
href={href}
|
||||||
target={session.url ? "_blank" : undefined}
|
target="_blank"
|
||||||
rel={session.url ? "noopener noreferrer" : undefined}
|
rel="noopener noreferrer"
|
||||||
className="nav-item session-item"
|
className="nav-item session-item"
|
||||||
title={`${session.display_name} (${session.status})`}
|
title={`${session.display_name} (${session.status})`}
|
||||||
>
|
>
|
||||||
@@ -44,7 +68,10 @@ export const AppShell = () => {
|
|||||||
const { sessions, setAllSessions } = useSessions();
|
const { sessions, setAllSessions } = useSessions();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isMobile = useMobileViewport();
|
const isMobile = useMobileViewport();
|
||||||
const isMobileTerminal = isMobile && location.pathname.includes("/instances/") && location.pathname.includes("/terminal");
|
const isMobileTerminal =
|
||||||
|
isMobile &&
|
||||||
|
location.pathname.includes("/instances/") &&
|
||||||
|
location.pathname.includes("/terminal");
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -66,19 +93,31 @@ export const AppShell = () => {
|
|||||||
|
|
||||||
if (isMobileTerminal) {
|
if (isMobileTerminal) {
|
||||||
return (
|
return (
|
||||||
|
<EventProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<NotificationProvider>
|
||||||
|
<EventToastBridge />
|
||||||
<div className="shell mobile-terminal-shell">
|
<div className="shell mobile-terminal-shell">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
</NotificationProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</EventProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<EventProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<NotificationProvider>
|
||||||
|
<EventToastBridge />
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
<header className="shell-header">
|
<header className="shell-header">
|
||||||
<Link className="brand" to="/">
|
<Link className="brand" to="/">
|
||||||
Headquarter
|
Headquarter
|
||||||
</Link>
|
</Link>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
|
<NotificationCenter isMobileTerminal={isMobileTerminal} />
|
||||||
<Link className="user-chip" to="/profile">
|
<Link className="user-chip" to="/profile">
|
||||||
{user?.name ?? "User"}
|
{user?.name ?? "User"}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -99,12 +138,16 @@ export const AppShell = () => {
|
|||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<aside className="shell-nav" aria-label="Primary navigation">
|
<aside className="shell-nav" aria-label="Primary navigation">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
const activeCount = sessions.filter((s) => s.status === "running").length;
|
const activeCount = sessions.filter(
|
||||||
|
(s) => s.status === "running",
|
||||||
|
).length;
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
className={({ isActive }) =>
|
||||||
|
isActive ? "nav-item nav-item-active" : "nav-item"
|
||||||
|
}
|
||||||
end={item.to === "/"}
|
end={item.to === "/"}
|
||||||
>
|
>
|
||||||
<Icon name={item.icon} size="sm" />
|
<Icon name={item.icon} size="sm" />
|
||||||
@@ -134,8 +177,16 @@ export const AppShell = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<MobileNav sessionCount={sessions.filter((s) => s.status === "running").length} />
|
<MobileNav
|
||||||
|
sessionCount={
|
||||||
|
sessions.filter((s) => s.status === "running").length
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
<StartToolFAB />
|
||||||
</div>
|
</div>
|
||||||
|
</NotificationProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</EventProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const CreateSessionForm = ({
|
|||||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
||||||
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
|
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
|
||||||
|
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
|
||||||
|
|
||||||
const [branches, setBranches] = useState<Branch[]>([]);
|
const [branches, setBranches] = useState<Branch[]>([]);
|
||||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||||
@@ -60,9 +61,8 @@ export const CreateSessionForm = ({
|
|||||||
const [progress, setProgress] = useState("");
|
const [progress, setProgress] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Load SSH keys when clone mode is shown
|
// Load SSH keys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showCloneMode) return;
|
|
||||||
const loadKeys = async () => {
|
const loadKeys = async () => {
|
||||||
try {
|
try {
|
||||||
const keys = await listSSHKeys();
|
const keys = await listSSHKeys();
|
||||||
@@ -72,7 +72,7 @@ export const CreateSessionForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
void loadKeys();
|
void loadKeys();
|
||||||
}, [showCloneMode]);
|
}, []);
|
||||||
|
|
||||||
// Load config profiles when tool type is selected
|
// Load config profiles when tool type is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -166,11 +166,18 @@ export const CreateSessionForm = ({
|
|||||||
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
||||||
? newBranchName
|
? newBranchName
|
||||||
: undefined,
|
: undefined,
|
||||||
selectedConfigProfile || undefined
|
selectedConfigProfile || undefined,
|
||||||
|
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
setProgress("Starting container...");
|
setProgress("Starting container...");
|
||||||
await startInstance(projectId, repoId, instance.id);
|
await startInstance(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
instance.id,
|
||||||
|
selectedConfigProfile || undefined,
|
||||||
|
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined
|
||||||
|
);
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
if (!fixedProjectId) setSelectedProject("");
|
if (!fixedProjectId) setSelectedProject("");
|
||||||
@@ -183,6 +190,7 @@ export const CreateSessionForm = ({
|
|||||||
setNewBranchName("");
|
setNewBranchName("");
|
||||||
setBaseBranch("");
|
setBaseBranch("");
|
||||||
setBranches([]);
|
setBranches([]);
|
||||||
|
setSelectedSshKeyIds([]);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
|
|
||||||
onSuccess?.(instance);
|
onSuccess?.(instance);
|
||||||
@@ -344,8 +352,54 @@ export const CreateSessionForm = ({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 5: Clone Mode & Branch */}
|
{/* Step 5: SSH Keys */}
|
||||||
{showCloneMode && hasToolType && renderStep("Repository Access", 5, true, false,
|
{hasToolType && renderStep("SSH Keys (optional)", 5, true, false,
|
||||||
|
<div className="form-field">
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||||||
|
{sshKeys.length === 0 && (
|
||||||
|
<span className="muted">No SSH keys configured.</span>
|
||||||
|
)}
|
||||||
|
{sshKeys.map((key) => (
|
||||||
|
<label
|
||||||
|
key={key.id}
|
||||||
|
className="checkbox-label"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
|
padding: "0.375rem 0.75rem",
|
||||||
|
background: "var(--panel)",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSshKeyIds.includes(key.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectedSshKeyIds((prev) => [...prev, key.id]);
|
||||||
|
} else {
|
||||||
|
setSelectedSshKeyIds((prev) =>
|
||||||
|
prev.filter((id) => id !== key.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
{key.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="hint" style={{ marginTop: "0.5rem" }}>
|
||||||
|
Selected keys will be mounted into the container at ~/.ssh
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 6: Clone Mode & Branch */}
|
||||||
|
{showCloneMode && hasToolType && renderStep("Repository Access", 6, true, false,
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
<div className="radio-group">
|
<div className="radio-group">
|
||||||
@@ -468,8 +522,8 @@ export const CreateSessionForm = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 6: Display Name */}
|
{/* Step 7: Display Name */}
|
||||||
{hasToolType && renderStep("Display Name (optional)", 6, true, !!displayName,
|
{hasToolType && renderStep("Display Name (optional)", 7, true, !!displayName,
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, act } from "@testing-library/react";
|
||||||
|
import { EventToastBridge } from "./event-toast-bridge";
|
||||||
|
import { useEventContext } from "../state/events";
|
||||||
|
import { getUserConfig } from "../api/settings";
|
||||||
|
import { handleEventToast } from "./toast-rules";
|
||||||
|
import type { InstanceEventPayload } from "../types/events";
|
||||||
|
|
||||||
|
vi.mock("../state/events", () => ({
|
||||||
|
useEventContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/settings", () => ({
|
||||||
|
getUserConfig: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./toast-rules", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("./toast-rules")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
handleEventToast: vi.fn(),
|
||||||
|
clearToastDedup: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockedUseEventContext = vi.mocked(useEventContext);
|
||||||
|
const mockedGetUserConfig = vi.mocked(getUserConfig);
|
||||||
|
const mockedHandleEventToast = vi.mocked(handleEventToast);
|
||||||
|
|
||||||
|
function makeEvent(
|
||||||
|
eventType: string,
|
||||||
|
overrides?: Partial<InstanceEventPayload>,
|
||||||
|
): InstanceEventPayload {
|
||||||
|
return {
|
||||||
|
event: eventType,
|
||||||
|
instance_id: "i-1",
|
||||||
|
status: undefined,
|
||||||
|
message: undefined,
|
||||||
|
metadata: {},
|
||||||
|
timestamp: "2026-05-29T10:00:00Z",
|
||||||
|
correlation_id: "c1",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushPromises() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("EventToastBridge preference checks", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
mockedGetUserConfig.mockResolvedValue({
|
||||||
|
theme: "system",
|
||||||
|
default_editor: null,
|
||||||
|
git_user_name: null,
|
||||||
|
git_user_email: null,
|
||||||
|
last_session_id: null,
|
||||||
|
notification_toast_level: "all",
|
||||||
|
notification_mute_categories: [],
|
||||||
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows toast when level is all and category not muted", async () => {
|
||||||
|
const event = makeEvent("instance.started");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses toast when level is none", async () => {
|
||||||
|
mockedGetUserConfig.mockResolvedValue({
|
||||||
|
notification_toast_level: "none",
|
||||||
|
notification_mute_categories: [],
|
||||||
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||||
|
const event = makeEvent("instance.started");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses info toast when level is errors", async () => {
|
||||||
|
mockedGetUserConfig.mockResolvedValue({
|
||||||
|
notification_toast_level: "errors",
|
||||||
|
notification_mute_categories: [],
|
||||||
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||||
|
const event = makeEvent("instance.started");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error toast when level is errors", async () => {
|
||||||
|
mockedGetUserConfig.mockResolvedValue({
|
||||||
|
notification_toast_level: "errors",
|
||||||
|
notification_mute_categories: [],
|
||||||
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||||
|
const event = makeEvent("instance.error");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses toast when category is muted", async () => {
|
||||||
|
mockedGetUserConfig.mockResolvedValue({
|
||||||
|
notification_toast_level: "all",
|
||||||
|
notification_mute_categories: ["instance"],
|
||||||
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||||
|
const event = makeEvent("instance.started");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies preference change immediately via custom event", async () => {
|
||||||
|
const event1 = makeEvent("instance.started");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event1],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
const { rerender } = render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("userconfig:updated", {
|
||||||
|
detail: { notification_toast_level: "none" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const event2 = makeEvent("instance.started");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event1, event2],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
rerender(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("muted category overrides all level", async () => {
|
||||||
|
mockedGetUserConfig.mockResolvedValue({
|
||||||
|
notification_toast_level: "all",
|
||||||
|
notification_mute_categories: ["instance"],
|
||||||
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||||
|
const event = makeEvent("instance.error");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplication still works with preferences", async () => {
|
||||||
|
const event = makeEvent("instance.started");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event, event],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unmapped event defaults to system/info and shows when level is all", async () => {
|
||||||
|
const event = makeEvent("system.announcement");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useEventContext } from "../state/events";
|
||||||
|
import {
|
||||||
|
handleEventToast,
|
||||||
|
mapEventToCategory,
|
||||||
|
mapEventToSeverity,
|
||||||
|
} from "./toast-rules";
|
||||||
|
import { getUserConfig } from "../api/settings";
|
||||||
|
import type { UserConfig } from "../api/settings";
|
||||||
|
|
||||||
|
interface ToastConfig {
|
||||||
|
notification_toast_level: string;
|
||||||
|
notification_mute_categories: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventToastBridge(): JSX.Element | null {
|
||||||
|
const { events } = useEventContext();
|
||||||
|
const processedRef = useRef<Set<string>>(new Set());
|
||||||
|
const [config, setConfig] = useState<ToastConfig | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUserConfig()
|
||||||
|
.then((c) => {
|
||||||
|
setConfig({
|
||||||
|
notification_toast_level: c.notification_toast_level ?? "all",
|
||||||
|
notification_mute_categories: c.notification_mute_categories ?? [],
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setConfig({
|
||||||
|
notification_toast_level: "all",
|
||||||
|
notification_mute_categories: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent<Partial<UserConfig>>).detail;
|
||||||
|
if (detail) {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
notification_toast_level:
|
||||||
|
detail.notification_toast_level ??
|
||||||
|
prev?.notification_toast_level ??
|
||||||
|
"all",
|
||||||
|
notification_mute_categories:
|
||||||
|
detail.notification_mute_categories ??
|
||||||
|
prev?.notification_mute_categories ??
|
||||||
|
[],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("userconfig:updated", handler);
|
||||||
|
return () => window.removeEventListener("userconfig:updated", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const key = `${event.correlation_id}:${event.timestamp}`;
|
||||||
|
if (processedRef.current.has(key)) continue;
|
||||||
|
processedRef.current.add(key);
|
||||||
|
|
||||||
|
const category = mapEventToCategory(event);
|
||||||
|
const severity = mapEventToSeverity(event);
|
||||||
|
|
||||||
|
if (config.notification_toast_level === "none") continue;
|
||||||
|
if (config.notification_toast_level === "errors" && severity !== "error")
|
||||||
|
continue;
|
||||||
|
if (config.notification_mute_categories.includes(category)) continue;
|
||||||
|
|
||||||
|
handleEventToast(event);
|
||||||
|
}
|
||||||
|
}, [events, config]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,83 +1,159 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import type { GitMount } from "../api/config_profiles";
|
import { validateGitUrl } from "../api/config_profiles";
|
||||||
|
import type { GitMount, GitMountMapping } from "../api/config_profiles";
|
||||||
|
|
||||||
interface GitMountEditorProps {
|
interface GitMountEditorProps {
|
||||||
mounts: GitMount[];
|
mounts: GitMount[];
|
||||||
onChange: (mounts: GitMount[]) => void;
|
onChange: (mounts: GitMount[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
function normalizeMount(mount: GitMount): GitMount {
|
||||||
|
// Auto-convert legacy source_path + target_path to mappings
|
||||||
|
if (
|
||||||
|
(!mount.mappings || mount.mappings.length === 0) &&
|
||||||
|
mount.source_path !== undefined &&
|
||||||
|
mount.target_path !== undefined
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
remote_url: mount.remote_url,
|
||||||
|
branch: mount.branch,
|
||||||
|
mappings: [
|
||||||
|
{
|
||||||
|
source_path: mount.source_path || ".",
|
||||||
|
target_path: mount.target_path,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return mount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMounts(mounts: GitMount[]): GitMount[] {
|
||||||
|
return mounts.map(normalizeMount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GitMountEditor = ({
|
||||||
|
mounts,
|
||||||
|
onChange,
|
||||||
|
}: GitMountEditorProps) => {
|
||||||
|
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() =>
|
||||||
|
normalizeMounts(mounts),
|
||||||
|
);
|
||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
const [newMount, setNewMount] = useState<GitMount>({
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
remote_url: "",
|
|
||||||
source_path: ".",
|
useEffect(() => {
|
||||||
target_path: "",
|
setNormalizedMounts(normalizeMounts(mounts));
|
||||||
branch: "",
|
}, [mounts]);
|
||||||
});
|
|
||||||
|
|
||||||
const handleAdd = (mount: GitMount) => {
|
const handleAdd = (mount: GitMount) => {
|
||||||
onChange([...mounts, mount]);
|
const updated = [...normalizedMounts, normalizeMount(mount)];
|
||||||
setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
setNormalizedMounts(updated);
|
||||||
|
onChange(updated);
|
||||||
|
setIsAdding(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = (index: number, updated: GitMount) => {
|
const handleUpdate = (index: number, updated: GitMount) => {
|
||||||
const updatedMounts = [...mounts];
|
const updatedMounts = [...normalizedMounts];
|
||||||
updatedMounts[index] = updated;
|
updatedMounts[index] = normalizeMount(updated);
|
||||||
|
setNormalizedMounts(updatedMounts);
|
||||||
onChange(updatedMounts);
|
onChange(updatedMounts);
|
||||||
setEditingIndex(null);
|
setEditingIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemove = (index: number) => {
|
const handleRemove = (index: number) => {
|
||||||
onChange(mounts.filter((_, i) => i !== index));
|
const updated = normalizedMounts.filter((_, i) => i !== index);
|
||||||
};
|
setNormalizedMounts(updated);
|
||||||
|
onChange(updated);
|
||||||
const validatePath = (path: string, isTarget: boolean): string | null => {
|
|
||||||
if (!path) return isTarget ? "Target path is required" : null;
|
|
||||||
if (path.includes("..")) return "Path cannot contain ..";
|
|
||||||
if (!isTarget && path.startsWith("/")) return "Source path must be relative";
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateUrl = (url: string): string | null => {
|
|
||||||
if (!url) return "Git URL is required";
|
|
||||||
if (!url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("git@") && !url.startsWith("ssh://")) {
|
|
||||||
return "Must be a valid git URL (https://, git@, or ssh://)";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="git-mount-editor">
|
<div className="git-mount-editor">
|
||||||
<h4 className="section-subtitle">Git Mounts</h4>
|
<h4 style={{ margin: "0 0 0.75rem 0" }}>Git Mounts</h4>
|
||||||
|
<p
|
||||||
|
className="muted"
|
||||||
|
style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}
|
||||||
|
>
|
||||||
|
Clone a repository once and mount multiple directories from it.
|
||||||
|
</p>
|
||||||
|
|
||||||
{mounts.length > 0 && (
|
{normalizedMounts.length > 0 && (
|
||||||
<div className="git-mount-list">
|
<div
|
||||||
{mounts.map((mount, index) => (
|
className="git-mount-list"
|
||||||
<div key={index} className="git-mount-item">
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.75rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{normalizedMounts.map((mount, index) => (
|
||||||
|
<div key={index} className="card" style={{ padding: "1rem" }}>
|
||||||
{editingIndex === index ? (
|
{editingIndex === index ? (
|
||||||
<GitMountForm
|
<GitMountForm
|
||||||
mount={mount}
|
mount={mount}
|
||||||
onSave={(updated) => handleUpdate(index, updated)}
|
onSave={(updated) => handleUpdate(index, updated)}
|
||||||
onCancel={() => setEditingIndex(null)}
|
onCancel={() => setEditingIndex(null)}
|
||||||
validatePath={validatePath}
|
|
||||||
validateUrl={validateUrl}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="git-mount-display">
|
<div>
|
||||||
<div className="git-mount-info">
|
<div
|
||||||
<span className="git-mount-repo">{mount.remote_url}</span>
|
style={{
|
||||||
<span className="git-mount-paths">
|
display: "flex",
|
||||||
{mount.source_path || "."} → {mount.target_path}
|
justifyContent: "space-between",
|
||||||
</span>
|
alignItems: "flex-start",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.9375rem",
|
||||||
|
marginBottom: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mount.remote_url}
|
||||||
{mount.branch && (
|
{mount.branch && (
|
||||||
<span className="git-mount-branch">@{mount.branch}</span>
|
<span
|
||||||
|
style={{
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontWeight: 400,
|
||||||
|
marginLeft: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
@{mount.branch}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="git-mount-actions">
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mount.mappings?.map((m, mi) => (
|
||||||
|
<div
|
||||||
|
key={mi}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.source_path || "."} → {m.target_path}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button"
|
className="ghost-button small"
|
||||||
onClick={() => setEditingIndex(index)}
|
onClick={() => setEditingIndex(index)}
|
||||||
title="Edit"
|
title="Edit"
|
||||||
>
|
>
|
||||||
@@ -85,7 +161,7 @@ export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-button danger"
|
className="ghost-button small"
|
||||||
onClick={() => handleRemove(index)}
|
onClick={() => handleRemove(index)}
|
||||||
title="Remove"
|
title="Remove"
|
||||||
>
|
>
|
||||||
@@ -93,23 +169,35 @@ export const GitMountEditor = ({ mounts, onChange }: GitMountEditorProps) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="git-mount-add">
|
{isAdding ? (
|
||||||
<h5>Add Git Mount</h5>
|
<div className="card" style={{ padding: "1rem" }}>
|
||||||
<GitMountForm
|
<GitMountForm
|
||||||
mount={newMount}
|
mount={{
|
||||||
|
remote_url: "",
|
||||||
|
branch: "",
|
||||||
|
mappings: [{ source_path: ".", target_path: "" }],
|
||||||
|
}}
|
||||||
onSave={handleAdd}
|
onSave={handleAdd}
|
||||||
onCancel={() => setNewMount({ remote_url: "", source_path: ".", target_path: "", branch: "" })}
|
onCancel={() => setIsAdding(false)}
|
||||||
validatePath={validatePath}
|
|
||||||
validateUrl={validateUrl}
|
|
||||||
isNew
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => setIsAdding(true)}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Add Git Mount
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -118,104 +206,364 @@ interface GitMountFormProps {
|
|||||||
mount: GitMount;
|
mount: GitMount;
|
||||||
onSave: (mount: GitMount) => void;
|
onSave: (mount: GitMount) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
validatePath: (path: string, isTarget: boolean) => string | null;
|
|
||||||
validateUrl: (url: string) => string | null;
|
|
||||||
isNew?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const GitMountForm = ({ mount, onSave, onCancel, validatePath, validateUrl, isNew }: GitMountFormProps) => {
|
type ValidationState =
|
||||||
const [form, setForm] = useState<GitMount>({ ...mount });
|
| { status: "idle" }
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
| { status: "loading" }
|
||||||
|
| { status: "valid"; branches: string[]; defaultBranch: string }
|
||||||
|
| { status: "suggestion"; suggestedUrl: string; message: string }
|
||||||
|
| { status: "invalid"; message: string };
|
||||||
|
|
||||||
const handleChange = (field: keyof GitMount, value: string) => {
|
const GitMountForm = ({
|
||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
mount,
|
||||||
if (errors[field]) {
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: GitMountFormProps) => {
|
||||||
|
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
|
||||||
|
const [branch, setBranch] = useState(mount.branch || "");
|
||||||
|
const [mappings, setMappings] = useState<GitMountMapping[]>(
|
||||||
|
mount.mappings?.length
|
||||||
|
? mount.mappings
|
||||||
|
: [{ source_path: ".", target_path: "" }],
|
||||||
|
);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [validation, setValidation] = useState<ValidationState>({
|
||||||
|
status: "idle",
|
||||||
|
});
|
||||||
|
|
||||||
|
const isUrlValidated =
|
||||||
|
validation.status === "valid" ||
|
||||||
|
(validation.status === "idle" && mount.remote_url.length > 0);
|
||||||
|
|
||||||
|
const handleCheckUrl = async () => {
|
||||||
|
if (!remoteUrl.trim()) {
|
||||||
|
setErrors((prev) => ({ ...prev, remote_url: "Git URL is required" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setValidation({ status: "loading" });
|
||||||
setErrors((prev) => {
|
setErrors((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[field];
|
delete next.remote_url;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await validateGitUrl(remoteUrl.trim());
|
||||||
|
if (result.valid && result.branches) {
|
||||||
|
setValidation({
|
||||||
|
status: "valid",
|
||||||
|
branches: result.branches,
|
||||||
|
defaultBranch: result.default_branch || "main",
|
||||||
|
});
|
||||||
|
if (!branch) {
|
||||||
|
setBranch(result.default_branch || "main");
|
||||||
|
}
|
||||||
|
if (result.suggested_url && result.suggested_url !== remoteUrl.trim()) {
|
||||||
|
setRemoteUrl(result.suggested_url);
|
||||||
|
}
|
||||||
|
} else if (result.suggested_url) {
|
||||||
|
setValidation({
|
||||||
|
status: "suggestion",
|
||||||
|
suggestedUrl: result.suggested_url,
|
||||||
|
message: result.error || "URL needs correction",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setValidation({
|
||||||
|
status: "invalid",
|
||||||
|
message: result.error || "Invalid repository URL",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setValidation({
|
||||||
|
status: "invalid",
|
||||||
|
message: "Failed to validate URL. Please try again.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySuggestion = () => {
|
||||||
|
if (validation.status === "suggestion") {
|
||||||
|
setRemoteUrl(validation.suggestedUrl);
|
||||||
|
setValidation({ status: "idle" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = (): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!remoteUrl.trim()) {
|
||||||
|
newErrors.remote_url = "Git URL is required";
|
||||||
|
} else if (
|
||||||
|
!remoteUrl.startsWith("http://") &&
|
||||||
|
!remoteUrl.startsWith("https://") &&
|
||||||
|
!remoteUrl.startsWith("git@") &&
|
||||||
|
!remoteUrl.startsWith("ssh://")
|
||||||
|
) {
|
||||||
|
newErrors.remote_url =
|
||||||
|
"Must be a valid git URL (https://, git@, or ssh://)";
|
||||||
|
}
|
||||||
|
|
||||||
|
mappings.forEach((m, i) => {
|
||||||
|
if (!m.target_path.trim()) {
|
||||||
|
newErrors[`mapping_${i}_target`] = "Target path is required";
|
||||||
|
}
|
||||||
|
if (m.source_path.includes("..")) {
|
||||||
|
newErrors[`mapping_${i}_source`] = "Source path cannot contain ..";
|
||||||
|
}
|
||||||
|
if (m.target_path.includes("..")) {
|
||||||
|
newErrors[`mapping_${i}_target`] = "Target path cannot contain ..";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!validate()) return;
|
||||||
|
onSave({
|
||||||
|
remote_url: remoteUrl.trim(),
|
||||||
|
branch: branch.trim() || undefined,
|
||||||
|
mappings: mappings.map((m) => ({
|
||||||
|
source_path: m.source_path.trim() || ".",
|
||||||
|
target_path: m.target_path.trim(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMapping = () => {
|
||||||
|
setMappings((prev) => [...prev, { source_path: ".", target_path: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMapping = (
|
||||||
|
index: number,
|
||||||
|
field: keyof GitMountMapping,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
setMappings((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = { ...next[index], [field]: value };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (errors[`mapping_${index}_${field}`]) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[`mapping_${index}_${field}`];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const removeMapping = (index: number) => {
|
||||||
const newErrors: Record<string, string> = {};
|
setMappings((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
|
||||||
const urlError = validateUrl(form.remote_url);
|
|
||||||
if (urlError) newErrors.remote_url = urlError;
|
|
||||||
|
|
||||||
const sourceError = validatePath(form.source_path || ".", false);
|
|
||||||
if (sourceError) newErrors.source_path = sourceError;
|
|
||||||
|
|
||||||
const targetError = validatePath(form.target_path, true);
|
|
||||||
if (targetError) newErrors.target_path = targetError;
|
|
||||||
|
|
||||||
if (Object.keys(newErrors).length > 0) {
|
|
||||||
setErrors(newErrors);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSave(form);
|
|
||||||
if (isNew) {
|
|
||||||
setForm({ remote_url: "", source_path: ".", target_path: "", branch: "" });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="git-mount-form">
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||||
<div className="form-row">
|
<div
|
||||||
<label>Git URL</label>
|
className="form-row"
|
||||||
|
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 2 }}>
|
||||||
|
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||||
|
Repository URL
|
||||||
|
</label>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.remote_url}
|
value={remoteUrl}
|
||||||
onChange={(e) => handleChange("remote_url", e.target.value)}
|
onChange={(e) => {
|
||||||
|
setRemoteUrl(e.target.value);
|
||||||
|
setValidation({ status: "idle" });
|
||||||
|
if (errors.remote_url) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next.remote_url;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder="https://github.com/user/repo.git"
|
placeholder="https://github.com/user/repo.git"
|
||||||
className={errors.remote_url ? "error" : ""}
|
className={`form-input ${errors.remote_url ? "error" : ""}`}
|
||||||
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<span className="hint">Repository URL (HTTPS or SSH)</span>
|
<button
|
||||||
{errors.remote_url && <span className="error-text">{errors.remote_url}</span>}
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={handleCheckUrl}
|
||||||
|
disabled={validation.status === "loading"}
|
||||||
|
>
|
||||||
|
{validation.status === "loading" ? (
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
) : (
|
||||||
|
"Check"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{errors.remote_url && (
|
||||||
<div className="form-row">
|
<span className="error-text">{errors.remote_url}</span>
|
||||||
<label>Source Path</label>
|
)}
|
||||||
|
{validation.status === "valid" && (
|
||||||
|
<span className="validation-status valid">
|
||||||
|
Repository is accessible (
|
||||||
|
{
|
||||||
|
(validation as Extract<ValidationState, { status: "valid" }>)
|
||||||
|
.branches.length
|
||||||
|
}{" "}
|
||||||
|
branches)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{validation.status === "suggestion" && (
|
||||||
|
<div className="url-suggestion">
|
||||||
|
<span>{validation.message}</span>
|
||||||
|
<div className="suggestion-actions">
|
||||||
|
<code className="suggested-url">{validation.suggestedUrl}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={applySuggestion}
|
||||||
|
>
|
||||||
|
Use this
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{validation.status === "invalid" && (
|
||||||
|
<span className="validation-status invalid">
|
||||||
|
{validation.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||||
|
Branch
|
||||||
|
</label>
|
||||||
|
{validation.status === "valid" ? (
|
||||||
|
<select
|
||||||
|
value={branch}
|
||||||
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
{(
|
||||||
|
validation as Extract<ValidationState, { status: "valid" }>
|
||||||
|
).branches.map((b) => (
|
||||||
|
<option key={b} value={b}>
|
||||||
|
{b}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.source_path || "."}
|
value={branch}
|
||||||
onChange={(e) => handleChange("source_path", e.target.value)}
|
onChange={(e) => setBranch(e.target.value)}
|
||||||
placeholder="e.g., . or configs/*.json"
|
placeholder="main"
|
||||||
className={errors.source_path ? "error" : ""}
|
className="form-input"
|
||||||
|
disabled={!isUrlValidated}
|
||||||
/>
|
/>
|
||||||
<span className="hint">Relative path in repo (supports glob patterns)</span>
|
)}
|
||||||
{errors.source_path && <span className="error-text">{errors.source_path}</span>}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div
|
||||||
<label>Target Path</label>
|
style={{
|
||||||
|
opacity: isUrlValidated ? 1 : 0.5,
|
||||||
|
pointerEvents: isUrlValidated ? "auto" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||||
|
Mappings
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
className="muted"
|
||||||
|
style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}
|
||||||
|
>
|
||||||
|
Source paths within the repo and where to mount them in the container.
|
||||||
|
{!isUrlValidated && (
|
||||||
|
<span style={{ color: "var(--warning)" }}>
|
||||||
|
{" "}
|
||||||
|
Validate the URL first.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
|
||||||
|
>
|
||||||
|
{mappings.map((mapping, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="form-row"
|
||||||
|
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.target_path}
|
value={mapping.source_path}
|
||||||
onChange={(e) => handleChange("target_path", e.target.value)}
|
onChange={(e) =>
|
||||||
placeholder="e.g., /app/config"
|
updateMapping(index, "source_path", e.target.value)
|
||||||
className={errors.target_path ? "error" : ""}
|
}
|
||||||
|
placeholder="packages/api"
|
||||||
|
className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`}
|
||||||
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<span className="hint">Use absolute path (e.g. /app/config). Relative paths need working_directory set in tool config.</span>
|
<span
|
||||||
{errors.target_path && <span className="error-text">{errors.target_path}</span>}
|
style={{
|
||||||
</div>
|
padding: "0.5rem 0",
|
||||||
|
color: "var(--muted)",
|
||||||
<div className="form-row">
|
fontSize: "0.875rem",
|
||||||
<label>Branch (optional)</label>
|
}}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.branch || ""}
|
value={mapping.target_path}
|
||||||
onChange={(e) => handleChange("branch", e.target.value)}
|
onChange={(e) =>
|
||||||
placeholder="e.g., main or v1.0"
|
updateMapping(index, "target_path", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="/app/api"
|
||||||
|
className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`}
|
||||||
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<span className="hint">Branch or tag to checkout</span>
|
{mappings.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button small"
|
||||||
|
onClick={() => removeMapping(index)}
|
||||||
|
title="Remove mapping"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{errors[`mapping_${index}_source`] && (
|
||||||
|
<span className="error-text">
|
||||||
|
{errors[`mapping_${index}_source`]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{errors[`mapping_${index}_target`] && (
|
||||||
|
<span className="error-text">
|
||||||
|
{errors[`mapping_${index}_target`]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button small"
|
||||||
|
onClick={addMapping}
|
||||||
|
style={{ marginTop: "0.5rem" }}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Add Mapping
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-actions">
|
<div
|
||||||
|
className="form-actions"
|
||||||
|
style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}
|
||||||
|
>
|
||||||
<button type="button" className="primary-button" onClick={handleSubmit}>
|
<button type="button" className="primary-button" onClick={handleSubmit}>
|
||||||
{isNew ? "Add" : "Save"}
|
Save
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="secondary-button" onClick={onCancel}>
|
<button type="button" className="secondary-button" onClick={onCancel}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
DotsSixVertical,
|
DotsSixVertical,
|
||||||
|
Bell,
|
||||||
|
CaretDown,
|
||||||
|
CaretRight,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
|
|
||||||
export type IconName =
|
export type IconName =
|
||||||
@@ -77,9 +80,18 @@ export type IconName =
|
|||||||
| "stop"
|
| "stop"
|
||||||
| "terminal"
|
| "terminal"
|
||||||
| "arrow-left"
|
| "arrow-left"
|
||||||
| "drag";
|
| "drag"
|
||||||
|
| "bell"
|
||||||
|
| "chevron-down"
|
||||||
|
| "chevron-right";
|
||||||
|
|
||||||
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
|
const iconMap: Record<
|
||||||
|
IconName,
|
||||||
|
React.ComponentType<{
|
||||||
|
size?: number | string;
|
||||||
|
weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
|
||||||
|
}>
|
||||||
|
> = {
|
||||||
dashboard: House,
|
dashboard: House,
|
||||||
projects: Folder,
|
projects: Folder,
|
||||||
repositories: GitBranch,
|
repositories: GitBranch,
|
||||||
@@ -120,6 +132,9 @@ const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; we
|
|||||||
terminal: Terminal,
|
terminal: Terminal,
|
||||||
"arrow-left": ArrowLeft,
|
"arrow-left": ArrowLeft,
|
||||||
drag: DotsSixVertical,
|
drag: DotsSixVertical,
|
||||||
|
bell: Bell,
|
||||||
|
"chevron-down": CaretDown,
|
||||||
|
"chevron-right": CaretRight,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IconProps {
|
export interface IconProps {
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import type { ToolInstance } from "../api/sessions";
|
import type { ToolInstance } from "../api/sessions";
|
||||||
import {
|
import {
|
||||||
checkInstanceHealth,
|
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
listInstances,
|
listInstances,
|
||||||
recreateInstanceTunnel,
|
|
||||||
restartInstance,
|
restartInstance,
|
||||||
startInstance,
|
startInstance,
|
||||||
stopInstance,
|
stopInstance,
|
||||||
@@ -14,8 +12,11 @@ import {
|
|||||||
import type { ToolType } from "../api/tool_types";
|
import type { ToolType } from "../api/tool_types";
|
||||||
import { CreateSessionForm } from "./create-session-form";
|
import { CreateSessionForm } from "./create-session-form";
|
||||||
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
||||||
|
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||||
|
import { useEventContext } from "../state/events";
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
const API_BASE_URL =
|
||||||
|
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
interface InstanceListProps {
|
interface InstanceListProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -25,7 +26,13 @@ interface InstanceListProps {
|
|||||||
toolTypes: ToolType[];
|
toolTypes: ToolType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTypes }: InstanceListProps) => {
|
export const InstanceList = ({
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
projectName,
|
||||||
|
repoName,
|
||||||
|
toolTypes,
|
||||||
|
}: InstanceListProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -35,13 +42,16 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
// Stop confirmation
|
// Stop confirmation
|
||||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Health check state
|
|
||||||
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
|
|
||||||
|
|
||||||
// Config profile selection for start/restart
|
// Config profile selection for start/restart
|
||||||
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
||||||
const [profileSelectInstanceId, setProfileSelectInstanceId] = useState<string | null>(null);
|
const [profileSelectInstanceId, setProfileSelectInstanceId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
|
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
|
||||||
|
const [selectedSshKeyIdsForAction, setSelectedSshKeyIdsForAction] = useState<
|
||||||
|
string[]
|
||||||
|
>([]);
|
||||||
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
|
|
||||||
// Per-instance busy state for actions
|
// Per-instance busy state for actions
|
||||||
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
|
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
|
||||||
@@ -58,60 +68,78 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
}
|
}
|
||||||
}, [projectId, repoId]);
|
}, [projectId, repoId]);
|
||||||
|
|
||||||
|
const { events } = useEventContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadInstances();
|
void loadInstances();
|
||||||
}, [loadInstances]);
|
}, [loadInstances]);
|
||||||
|
|
||||||
// Health check polling
|
// Lightweight list refresh every 60 seconds for resilience
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const runningInstances = instances.filter(i => i.status === "running" && i.url?.startsWith("http"));
|
const interval = setInterval(() => void loadInstances(), 60000);
|
||||||
if (runningInstances.length === 0) return;
|
|
||||||
|
|
||||||
const checkHealth = async () => {
|
|
||||||
for (const instance of runningInstances) {
|
|
||||||
try {
|
|
||||||
const health = await checkInstanceHealth(projectId, repoId, instance.id);
|
|
||||||
setHealthStatus(prev => ({
|
|
||||||
...prev,
|
|
||||||
[instance.id]: { healthy: health.healthy, lastCheck: Date.now() }
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
setHealthStatus(prev => ({
|
|
||||||
...prev,
|
|
||||||
[instance.id]: { healthy: false, lastCheck: Date.now() }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check immediately
|
|
||||||
void checkHealth();
|
|
||||||
|
|
||||||
// Then every 30 seconds
|
|
||||||
const interval = setInterval(() => void checkHealth(), 30000);
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [instances, projectId, repoId]);
|
}, [loadInstances]);
|
||||||
|
|
||||||
|
// Real-time status updates from SSE events
|
||||||
|
useEffect(() => {
|
||||||
|
if (events.length === 0) return;
|
||||||
|
const latestEvent = events[events.length - 1];
|
||||||
|
const statusEvents = [
|
||||||
|
"instance.started",
|
||||||
|
"instance.health_changed",
|
||||||
|
"instance.error",
|
||||||
|
"instance.stopped",
|
||||||
|
"instance.restarted",
|
||||||
|
];
|
||||||
|
if (!statusEvents.includes(latestEvent.event)) return;
|
||||||
|
|
||||||
|
setInstances((prev) =>
|
||||||
|
prev.map((inst) =>
|
||||||
|
inst.id === latestEvent.instance_id
|
||||||
|
? { ...inst, status: latestEvent.status ?? inst.status }
|
||||||
|
: inst,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
const handleCreateSuccess = async () => {
|
const handleCreateSuccess = async () => {
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadConfigProfiles = useCallback(async (toolTypeId: string) => {
|
const loadConfigProfiles = useCallback(
|
||||||
|
async (toolTypeId: string) => {
|
||||||
try {
|
try {
|
||||||
const profiles = await listConfigProfiles(projectId, toolTypeId);
|
const [profiles, keys] = await Promise.all([
|
||||||
|
listConfigProfiles(projectId, toolTypeId),
|
||||||
|
listSSHKeys(),
|
||||||
|
]);
|
||||||
setConfigProfiles(profiles);
|
setConfigProfiles(profiles);
|
||||||
|
setSshKeys(keys);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}, [projectId]);
|
},
|
||||||
|
[projectId],
|
||||||
|
);
|
||||||
|
|
||||||
const handleStart = async (instanceId: string, configProfileId?: string) => {
|
const handleStart = async (
|
||||||
|
instanceId: string,
|
||||||
|
configProfileId?: string,
|
||||||
|
sshKeyIds?: string[],
|
||||||
|
) => {
|
||||||
setBusyInstanceId(instanceId);
|
setBusyInstanceId(instanceId);
|
||||||
try {
|
try {
|
||||||
await startInstance(projectId, repoId, instanceId, configProfileId);
|
await startInstance(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
instanceId,
|
||||||
|
configProfileId,
|
||||||
|
sshKeyIds,
|
||||||
|
);
|
||||||
setProfileSelectInstanceId(null);
|
setProfileSelectInstanceId(null);
|
||||||
setSelectedProfileForAction("");
|
setSelectedProfileForAction("");
|
||||||
|
setSelectedSshKeyIdsForAction([]);
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to start instance");
|
setError("Failed to start instance");
|
||||||
@@ -133,12 +161,23 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestart = async (instanceId: string, configProfileId?: string) => {
|
const handleRestart = async (
|
||||||
|
instanceId: string,
|
||||||
|
configProfileId?: string,
|
||||||
|
sshKeyIds?: string[],
|
||||||
|
) => {
|
||||||
setBusyInstanceId(instanceId);
|
setBusyInstanceId(instanceId);
|
||||||
try {
|
try {
|
||||||
await restartInstance(projectId, repoId, instanceId, configProfileId);
|
await restartInstance(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
instanceId,
|
||||||
|
configProfileId,
|
||||||
|
sshKeyIds,
|
||||||
|
);
|
||||||
setProfileSelectInstanceId(null);
|
setProfileSelectInstanceId(null);
|
||||||
setSelectedProfileForAction("");
|
setSelectedProfileForAction("");
|
||||||
|
setSelectedSshKeyIdsForAction([]);
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to restart instance");
|
setError("Failed to restart instance");
|
||||||
@@ -153,7 +192,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
try {
|
try {
|
||||||
await deleteInstance(projectId, repoId, instanceId);
|
await deleteInstance(projectId, repoId, instanceId);
|
||||||
// Update state immediately instead of reloading
|
// Update state immediately instead of reloading
|
||||||
setInstances(prev => prev.filter(i => i.id !== instanceId));
|
setInstances((prev) => prev.filter((i) => i.id !== instanceId));
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to delete instance");
|
setError("Failed to delete instance");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -161,22 +200,15 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRecreateTunnel = async (instanceId: string) => {
|
|
||||||
setBusyInstanceId(instanceId);
|
|
||||||
try {
|
|
||||||
await recreateInstanceTunnel(projectId, repoId, instanceId);
|
|
||||||
await loadInstances();
|
|
||||||
} catch {
|
|
||||||
setError("Failed to recreate tunnel");
|
|
||||||
} finally {
|
|
||||||
setBusyInstanceId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "running":
|
case "running":
|
||||||
return "var(--success)";
|
return "var(--success)";
|
||||||
|
case "starting":
|
||||||
|
case "probing":
|
||||||
|
return "var(--info)";
|
||||||
|
case "unhealthy":
|
||||||
|
return "var(--warning)";
|
||||||
case "error":
|
case "error":
|
||||||
return "var(--danger)";
|
return "var(--danger)";
|
||||||
case "pending":
|
case "pending":
|
||||||
@@ -187,14 +219,6 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isTunnelUnhealthy = (instance: ToolInstance) => {
|
|
||||||
if (instance.status !== "running") return false;
|
|
||||||
if (!instance.url?.startsWith("http")) return false;
|
|
||||||
const health = healthStatus[instance.id];
|
|
||||||
if (!health) return false;
|
|
||||||
return !health.healthy;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="instance-list">
|
<div className="instance-list">
|
||||||
<div className="instance-list-header">
|
<div className="instance-list-header">
|
||||||
@@ -209,9 +233,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && <div className="error-message">{error}</div>}
|
||||||
<div className="error-message">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="muted">Loading instances...</p>
|
<p className="muted">Loading instances...</p>
|
||||||
@@ -220,7 +242,10 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
) : (
|
) : (
|
||||||
<div className="instance-grid">
|
<div className="instance-grid">
|
||||||
{instances.map((instance) => (
|
{instances.map((instance) => (
|
||||||
<div key={instance.id} className={`instance-card ${busyInstanceId === instance.id ? "busy" : ""}`}>
|
<div
|
||||||
|
key={instance.id}
|
||||||
|
className={`instance-card ${busyInstanceId === instance.id ? "busy" : ""}`}
|
||||||
|
>
|
||||||
{busyInstanceId === instance.id && (
|
{busyInstanceId === instance.id && (
|
||||||
<div className="instance-busy-overlay">
|
<div className="instance-busy-overlay">
|
||||||
<Icon name="loading" size="md" />
|
<Icon name="loading" size="md" />
|
||||||
@@ -234,26 +259,29 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
style={{ backgroundColor: getStatusColor(instance.status) }}
|
style={{ backgroundColor: getStatusColor(instance.status) }}
|
||||||
/>
|
/>
|
||||||
{instance.status}
|
{instance.status}
|
||||||
{isTunnelUnhealthy(instance) && (
|
|
||||||
<span className="error-badge" title="Tunnel unreachable">
|
|
||||||
<Icon name="warning" size="sm" />
|
|
||||||
tunnel error
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{instance.selected_config_profile_id && (
|
{instance.selected_config_profile_id && (
|
||||||
<div className="instance-profile">
|
<div className="instance-profile">
|
||||||
<span className="badge">
|
<span className="badge">
|
||||||
Profile: {configProfiles.find((p) => p.id === instance.selected_config_profile_id)?.name || instance.selected_config_profile_id}
|
Profile:{" "}
|
||||||
|
{configProfiles.find(
|
||||||
|
(p) => p.id === instance.selected_config_profile_id,
|
||||||
|
)?.name || instance.selected_config_profile_id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="instance-actions">
|
<div className="instance-actions">
|
||||||
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
|
{instance.status === "running" &&
|
||||||
|
instance.url &&
|
||||||
|
instance.tool_type_interfaces.includes("web") && (
|
||||||
<>
|
<>
|
||||||
<a
|
<a
|
||||||
href={instance.url.startsWith("http") ? instance.url : `${API_BASE_URL}${instance.url}`}
|
href={
|
||||||
|
instance.url.startsWith("http")
|
||||||
|
? instance.url
|
||||||
|
: `${API_BASE_URL}${instance.url}`
|
||||||
|
}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
@@ -261,24 +289,15 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
<Icon name="external" size="sm" />
|
<Icon name="external" size="sm" />
|
||||||
Open
|
Open
|
||||||
</a>
|
</a>
|
||||||
{isTunnelUnhealthy(instance) && (
|
|
||||||
<button
|
|
||||||
className="secondary-button small warning"
|
|
||||||
onClick={() => void handleRecreateTunnel(instance.id)}
|
|
||||||
type="button"
|
|
||||||
title="Recreate tunnel"
|
|
||||||
disabled={busyInstanceId === instance.id}
|
|
||||||
>
|
|
||||||
<Icon name="refresh" size="sm" />
|
|
||||||
Fix Tunnel
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{instance.status === "running" && instance.tool_type_interfaces.includes("terminal") && (
|
{instance.status === "running" &&
|
||||||
|
instance.tool_type_interfaces.includes("terminal") && (
|
||||||
<button
|
<button
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
|
onClick={() =>
|
||||||
|
navigate(`/instances/${instance.id}/terminal`)
|
||||||
|
}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busyInstanceId === instance.id}
|
disabled={busyInstanceId === instance.id}
|
||||||
>
|
>
|
||||||
@@ -292,7 +311,9 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
<div className="inline-profile-select">
|
<div className="inline-profile-select">
|
||||||
<select
|
<select
|
||||||
value={selectedProfileForAction}
|
value={selectedProfileForAction}
|
||||||
onChange={(e) => setSelectedProfileForAction(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setSelectedProfileForAction(e.target.value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<option value="">Default (none)</option>
|
<option value="">Default (none)</option>
|
||||||
{configProfiles.map((p) => (
|
{configProfiles.map((p) => (
|
||||||
@@ -301,9 +322,61 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "0.25rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sshKeys.map((key) => (
|
||||||
|
<label
|
||||||
|
key={key.id}
|
||||||
|
className="checkbox-label"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSshKeyIdsForAction.includes(
|
||||||
|
key.id,
|
||||||
|
)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectedSshKeyIdsForAction(
|
||||||
|
(prev) => [...prev, key.id],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelectedSshKeyIdsForAction(
|
||||||
|
(prev) =>
|
||||||
|
prev.filter(
|
||||||
|
(id) =>
|
||||||
|
id !== key.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{key.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="primary-button small"
|
className="primary-button small"
|
||||||
onClick={() => void handleStart(instance.id, selectedProfileForAction || undefined)}
|
onClick={() =>
|
||||||
|
void handleStart(
|
||||||
|
instance.id,
|
||||||
|
selectedProfileForAction || undefined,
|
||||||
|
selectedSshKeyIdsForAction.length > 0
|
||||||
|
? selectedSshKeyIdsForAction
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busyInstanceId === instance.id}
|
disabled={busyInstanceId === instance.id}
|
||||||
>
|
>
|
||||||
@@ -315,6 +388,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setProfileSelectInstanceId(null);
|
setProfileSelectInstanceId(null);
|
||||||
setSelectedProfileForAction("");
|
setSelectedProfileForAction("");
|
||||||
|
setSelectedSshKeyIdsForAction([]);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busyInstanceId === instance.id}
|
disabled={busyInstanceId === instance.id}
|
||||||
@@ -326,12 +400,19 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
<button
|
<button
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const toolType = toolTypes.find((t) => t.id === instance.tool_type_id);
|
const toolType = toolTypes.find(
|
||||||
|
(t) => t.id === instance.tool_type_id,
|
||||||
|
);
|
||||||
if (toolType) {
|
if (toolType) {
|
||||||
void loadConfigProfiles(toolType.id);
|
void loadConfigProfiles(toolType.id);
|
||||||
}
|
}
|
||||||
setProfileSelectInstanceId(instance.id);
|
setProfileSelectInstanceId(instance.id);
|
||||||
setSelectedProfileForAction(instance.selected_config_profile_id || "");
|
setSelectedProfileForAction(
|
||||||
|
instance.selected_config_profile_id || "",
|
||||||
|
);
|
||||||
|
setSelectedSshKeyIdsForAction(
|
||||||
|
instance.ssh_key_ids || [],
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busyInstanceId === instance.id}
|
disabled={busyInstanceId === instance.id}
|
||||||
@@ -378,7 +459,9 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
<div className="inline-profile-select">
|
<div className="inline-profile-select">
|
||||||
<select
|
<select
|
||||||
value={selectedProfileForAction}
|
value={selectedProfileForAction}
|
||||||
onChange={(e) => setSelectedProfileForAction(e.target.value)}
|
onChange={(e) =>
|
||||||
|
setSelectedProfileForAction(e.target.value)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<option value="">Default (none)</option>
|
<option value="">Default (none)</option>
|
||||||
{configProfiles.map((p) => (
|
{configProfiles.map((p) => (
|
||||||
@@ -387,9 +470,61 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "0.25rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sshKeys.map((key) => (
|
||||||
|
<label
|
||||||
|
key={key.id}
|
||||||
|
className="checkbox-label"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSshKeyIdsForAction.includes(
|
||||||
|
key.id,
|
||||||
|
)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectedSshKeyIdsForAction(
|
||||||
|
(prev) => [...prev, key.id],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelectedSshKeyIdsForAction(
|
||||||
|
(prev) =>
|
||||||
|
prev.filter(
|
||||||
|
(id) =>
|
||||||
|
id !== key.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{key.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="primary-button small"
|
className="primary-button small"
|
||||||
onClick={() => void handleRestart(instance.id, selectedProfileForAction || undefined)}
|
onClick={() =>
|
||||||
|
void handleRestart(
|
||||||
|
instance.id,
|
||||||
|
selectedProfileForAction || undefined,
|
||||||
|
selectedSshKeyIdsForAction.length > 0
|
||||||
|
? selectedSshKeyIdsForAction
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busyInstanceId === instance.id}
|
disabled={busyInstanceId === instance.id}
|
||||||
>
|
>
|
||||||
@@ -401,6 +536,7 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setProfileSelectInstanceId(null);
|
setProfileSelectInstanceId(null);
|
||||||
setSelectedProfileForAction("");
|
setSelectedProfileForAction("");
|
||||||
|
setSelectedSshKeyIdsForAction([]);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busyInstanceId === instance.id}
|
disabled={busyInstanceId === instance.id}
|
||||||
@@ -412,12 +548,19 @@ export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTyp
|
|||||||
<button
|
<button
|
||||||
className="ghost-button small"
|
className="ghost-button small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const toolType = toolTypes.find((t) => t.id === instance.tool_type_id);
|
const toolType = toolTypes.find(
|
||||||
|
(t) => t.id === instance.tool_type_id,
|
||||||
|
);
|
||||||
if (toolType) {
|
if (toolType) {
|
||||||
void loadConfigProfiles(toolType.id);
|
void loadConfigProfiles(toolType.id);
|
||||||
}
|
}
|
||||||
setProfileSelectInstanceId(instance.id);
|
setProfileSelectInstanceId(instance.id);
|
||||||
setSelectedProfileForAction(instance.selected_config_profile_id || "");
|
setSelectedProfileForAction(
|
||||||
|
instance.selected_config_profile_id || "",
|
||||||
|
);
|
||||||
|
setSelectedSshKeyIdsForAction(
|
||||||
|
instance.ssh_key_ids || [],
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busyInstanceId === instance.id}
|
disabled={busyInstanceId === instance.id}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import { extractErrorMessage } from "../utils/errors";
|
import { extractErrorMessage } from "../utils/errors";
|
||||||
import {
|
import {
|
||||||
@@ -170,11 +170,20 @@ export const ManifestEditor = ({
|
|||||||
baseDefinitionId,
|
baseDefinitionId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Notify parent of changes
|
// Notify parent of changes — only when built manifest actually differs
|
||||||
|
// from what we last sent, to avoid feedback loops with the manifest prop.
|
||||||
|
const lastSentRef = useRef<string>("");
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const m = buildManifest();
|
const m = buildManifest();
|
||||||
onChange(m);
|
const serialized = JSON.stringify(m);
|
||||||
}, [buildManifest, onChange]);
|
if (serialized !== lastSentRef.current) {
|
||||||
|
lastSentRef.current = serialized;
|
||||||
|
onChangeRef.current(m);
|
||||||
|
}
|
||||||
|
}, [buildManifest]);
|
||||||
|
|
||||||
const handlePreview = async () => {
|
const handlePreview = async () => {
|
||||||
if (!definitionId) {
|
if (!definitionId) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user