Phase 23: per-stack secrets & configs (compose file-based), local + agent (0.29.0)
Manage Docker secrets and configs per stack from a new Secrets tab on Stack/ RemoteStackDetail. Content is stored as files inside the stack dir (.secrets/<name>, .configs/<name>; dir 0700 / file 0600) and referenced from the compose file with relative `file:` paths, so the daemon reads them without any HOST_ROOT_PREFIX dependency. Content is write-only — the API only ever returns metadata (name, kind, size). - secret_service: write/delete/list (metadata only)/exists/rel_path/attach/detach; name validation rejects traversal/hidden/separators, content capped at 1 MiB. - compose_edit_service: add/remove secret and config (top-level defs pruned when no service still references them). - routers/secrets.py (admin-only, audit secret.*) + agent endpoints + multi-host proxy (audit agent.secret.*). - Frontend SecretsPanel (create/list/delete + per-row attach/detach to a service; config rows take a mount target), agentId-aware for remote stacks. Verified: name-sandbox + perms + metadata-only listing unit-tested; compose add/remove round-trips to clean YAML; py_compile + backend/agent/frontend image builds + route smoke-test (local/agent/proxy). Live exec check (/run/secrets/<name> on a deployed stack) and swarm path are hardware-verify debt (swarm dropped: A). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
255c8441c6
commit
6464e0677c
@@ -10,7 +10,9 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
||||
> network attach) + Phase 12 (File browser) + Phase 13 (Multi-host networks &
|
||||
> images) + Phase 14 (Multi-host file browser) + Phase 15 (Dashboard stack
|
||||
> resource usage) + Phase 16 (Volumes page, multi-host) + Phase 17 (Multi-host
|
||||
> dashboard) complete.
|
||||
> dashboard) + Phase 18 (Image prune) + Phase 19 (Compose validate & diff) +
|
||||
> Phase 20 (Container management) + Phase 21 (Container terminal) + Phase 22
|
||||
> (Auto-update) + Phase 23 (Secrets & configs) complete.
|
||||
|
||||
## What works today (Phase 1)
|
||||
|
||||
@@ -153,6 +155,26 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
||||
container or connect any container on the host (`POST /api/networks/{id}/connect`
|
||||
/ `/disconnect`).
|
||||
|
||||
### Phase 23 — Secrets & configs (compose file-based)
|
||||
|
||||
- A **Secrets** tab on the stack detail page manages per-stack Docker
|
||||
**secrets** and **configs**: create one by name + content, list them (name,
|
||||
kind, size — content is **never** returned by the API), and delete. Content is
|
||||
write-only: once saved it is cleared from the form and cannot be read back.
|
||||
- Files are stored inside the stack's own directory (`<stack_dir>/.secrets/<name>`
|
||||
/ `.configs/<name>`, dir `0700` / file `0600`) and referenced from the compose
|
||||
file with a **relative** `file:` path, so the Docker daemon reads them with no
|
||||
`HOST_ROOT_PREFIX` dependency — exactly as if dropped next to `compose.yaml`.
|
||||
- **Attach/detach** wires a stored secret/config into a chosen service: secrets
|
||||
appear at `/run/secrets/<name>`, configs mount at a target path you specify. The
|
||||
compose file is rewritten in place (top-level `secrets:`/`configs:` defs are
|
||||
pruned when no service still uses them); **redeploy the stack to apply**.
|
||||
- **Admin-only** (secrets are sensitive); every write/delete/attach/detach is
|
||||
audited (`secret.*`). Works for **remote stacks** too — the agent stores the
|
||||
files on its own host (`/agent/stacks/{id}/secrets/*`, proxied via
|
||||
`/api/agents/{id}/stacks/{id}/secrets/*`). Names are validated against path
|
||||
traversal (single component, no `..`, no leading dot); content capped at 1 MiB.
|
||||
|
||||
### Phase 22 — Auto-update (Watchtower-style)
|
||||
|
||||
- A per-stack **Auto-update** policy (on the stack Overview tab): when the
|
||||
|
||||
+51
-42
@@ -5,7 +5,7 @@ Each phase ships independently following the standing release checklist
|
||||
(bump `backend/main.py` + `backend/agent_app.py` AGENT_VERSION +
|
||||
`frontend/package.json` → build backend→agent→frontend `:VERSION`+`:latest` →
|
||||
py_compile + `tsc -b && vite build` → route smoke-test → push all 3 → README →
|
||||
git commit + push → update memory). Current released version: **0.26.0**.
|
||||
git commit + push → update memory). Current released version: **0.29.0**.
|
||||
|
||||
Order: **21 → 22 → 23** (Terminal is highest-value and self-contained; Secrets
|
||||
is most design-ambiguous, left last).
|
||||
@@ -144,58 +144,67 @@ either auto pull+redeploy or just notify. Builds on the existing
|
||||
|
||||
---
|
||||
|
||||
## Phase 23 — Docker Secrets & Configs ☐ NOT STARTED → target 0.29.0
|
||||
## Phase 23 — Docker Secrets & Configs ☑ DONE — shipped 0.29.0
|
||||
|
||||
**DESIGN DECISION TO MAKE FIRST (ask user / decide at phase start):** Docker
|
||||
`secret`/`config` objects are a **Swarm** feature. Two interpretations:
|
||||
- (A) **Compose file-based secrets** (`secrets:` top-level with `file:` +
|
||||
per-service `secrets:`) — works in plain compose, the relevant one for a
|
||||
compose manager. **Recommended default.**
|
||||
- (B) **Swarm secrets/configs** via `client.secrets`/`client.configs` — only if
|
||||
swarm mode is active (detect `client.info()["Swarm"]["LocalNodeState"]=="active"`).
|
||||
**DECISION (user, 2026-06-09): (A) Compose file-based secrets.** Swarm path (B)
|
||||
dropped. **Sub-decision (Claude's call): per-stack storage with RELATIVE paths**
|
||||
— secret/config files live in `<stack_dir>/.secrets/<name>` and `.configs/<name>`,
|
||||
referenced as `file: ./.secrets/<name>`. Compose resolves `file:` relative to the
|
||||
compose file (which is in the stack dir = a host bind-mount), so the daemon reads
|
||||
it with NO `HOST_ROOT_PREFIX` dependency. Secrets are therefore per-stack (matches
|
||||
how compose scopes them), managed from a **Secrets tab on StackDetail /
|
||||
RemoteStackDetail** (not the new-stack editor, which has no dir yet).
|
||||
|
||||
Plan assumes **(A)**, with (B) surfaced only when swarm is detected.
|
||||
**As shipped — deviations from the plan (both simplifications):**
|
||||
- **No DB model.** Both content *and* metadata live on disk; `list` derives name/
|
||||
size/mtime from the filesystem. A `models/secret.py` would only duplicate that,
|
||||
so it was dropped — there is nothing to keep in sync.
|
||||
- **Files live in the stack dir, not a separate sandbox.** `<stack_dir>/.secrets/`
|
||||
and `.configs/` (per-stack, relative `file:` refs) — this is exactly what
|
||||
compose expects and removes the `HOST_ROOT_PREFIX` dependency. Sandboxing comes
|
||||
from strict name validation (single component, no `..`, no leading dot, no sep).
|
||||
- **Routes are stack-scoped:** `/api/stacks/{stack_id}/secrets`, not `/api/secrets`.
|
||||
- **UI is a Secrets tab on Stack/RemoteStackDetail**, not a Settings page — a stack
|
||||
must exist (have a dir) before it can hold secrets, matching compose's scoping.
|
||||
- **Swarm path not built** (decision A dropped B); no `swarm_active()` guard.
|
||||
|
||||
### Backend
|
||||
- ☐ `models/secret.py` — `ManagedSecret(id, name, scope [global|stack], stack_id
|
||||
nullable, kind [secret|config], created, agent_id nullable)`. Content NOT in
|
||||
DB — stored on disk.
|
||||
- ☐ `services/secret_service.py`:
|
||||
- store secret files under a sandboxed dir `<STACKS_DIR>/.stackpilot-secrets/`
|
||||
(chmod 700 dir, 600 files); `create(name, content)`, `update(name, content)`,
|
||||
`delete(name)`, `list()` (metadata only — never return content; mask), and
|
||||
`path_for(name)` for compose `file:` refs.
|
||||
- if swarm active: also expose `client.secrets.list/create/remove` +
|
||||
`client.configs.*` (interpretation B), behind a `swarm_active()` guard.
|
||||
- ☐ `compose_edit_service`: `add_secret(yaml, service, secret_name)` — injects
|
||||
top-level `secrets: {<name>: {file: <path>}}` + per-service `secrets: [<name>]`;
|
||||
`remove_secret(...)`. Same for configs.
|
||||
- ☐ `routers/secrets.py` (prefix `/api/secrets`): CRUD (admin, audit
|
||||
`secret.*`), content write-only. Agent `/agent/secrets/*` + proxy
|
||||
`/api/agents/{id}/secrets/*` for multi-host (reuse the patterns).
|
||||
- ☑ `services/secret_service.py` — file-based store under `<stack_dir>/.secrets`
|
||||
& `.configs` (dir 0700, file 0600); `write_secret`/`delete_secret`/`list_all`
|
||||
(metadata only, never content)/`exists`/`rel_path`/`attach`/`detach`. 1 MiB cap;
|
||||
name validation rejects traversal/hidden/separators.
|
||||
- ☑ `compose_edit_service`: `add_secret`/`remove_secret` (top-level
|
||||
`secrets: {<name>: {file: <path>}}` + per-service list) and `add_config`/
|
||||
`remove_config` (with `source`/`target` mount); top-level defs pruned when unused.
|
||||
- ☑ `routers/secrets.py` (prefix `/api/stacks/{stack_id}/secrets`): list/write/
|
||||
delete/attach/detach, admin-only, audit `secret.*`, content write-only.
|
||||
- ☑ Agent `/agent/stacks/{stack_id}/secrets/*` (agent_app.py) + proxy
|
||||
`/api/agents/{id}/stacks/{stack_id}/secrets/*` (agents.py), audit `agent.secret.*`.
|
||||
|
||||
### Frontend
|
||||
- ☐ Settings → **Secrets & Configs** section (or a dedicated page): list (name,
|
||||
scope, kind, created), create (name + content textarea, content masked after),
|
||||
delete. Multi-host host-switcher like Files.
|
||||
- ☐ Editor helper panel: a **Secrets** wizard tab to attach an existing secret/
|
||||
config to a service (writes the compose `secrets:` block via
|
||||
`compose_edit_service`).
|
||||
- ☑ `SecretsPanel` (api/secrets.ts + components/stacks/SecretsPanel.tsx): create
|
||||
(type/name/content; content cleared after save, never re-shown), list (name,
|
||||
kind, size), delete, and per-row attach/detach to a service (config rows take a
|
||||
mount target). Admin-gated. Wired as a **Secrets** tab on both StackDetail and
|
||||
RemoteStackDetail (agentId-aware → multi-host).
|
||||
|
||||
### Verify
|
||||
- ☐ Create a file-based secret, attach to a service via the wizard, deploy, exec
|
||||
in and confirm `/run/secrets/<name>` is present with the content.
|
||||
- ☐ Sandbox: secret files can't escape the secrets dir; content never returned by list.
|
||||
- ☐ If swarm active on the build host, smoke-test the swarm path too (likely
|
||||
NOT active here → note as hardware-verify debt).
|
||||
- ☐ py_compile + build + route smoke-test.
|
||||
- ☑ Sandbox: traversal/hidden/separator names rejected; dir 0700 / file 0600;
|
||||
`list` returns metadata only, never content (unit-tested in the backend image).
|
||||
- ☑ compose round-trip: add secret+config → remove both → back to clean YAML
|
||||
(top-level defs pruned). add/remove for secrets and configs unit-tested.
|
||||
- ☑ py_compile + backend image build + frontend `tsc -b && vite build` + route
|
||||
smoke-test (local CRUD/attach/detach, agent, proxy all registered).
|
||||
- ☐ **Live hardware-verify debt:** create a secret, attach via the panel, deploy,
|
||||
exec in and confirm `/run/secrets/<name>` holds the content (needs a running
|
||||
stack on real hardware). Swarm path intentionally not built (decision A).
|
||||
|
||||
### Open risks
|
||||
- Swarm-vs-compose decision (above). Confirm with user before coding.
|
||||
### Open risks (carried)
|
||||
- Secret file ownership/permissions inside the container vs on host (file-based
|
||||
secrets mount the host file → uid/gid must be readable by the service user).
|
||||
- HOST_ROOT_PREFIX / multi-host: the secret file must live where that host's
|
||||
Docker daemon can read it (agent stores on its own host).
|
||||
- Multi-host: the agent stores the file on its own host, so the secret lives where
|
||||
that host's Docker daemon can read it — verified by design (relative `file:`),
|
||||
pending the live exec check above.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+74
-1
@@ -39,6 +39,7 @@ from config import settings
|
||||
from docker_client import DockerError, get_client, safe_call
|
||||
from services import (
|
||||
backup_service,
|
||||
compose_edit_service,
|
||||
compose_service,
|
||||
container_service,
|
||||
device_service,
|
||||
@@ -46,6 +47,7 @@ from services import (
|
||||
file_service,
|
||||
image_service,
|
||||
network_service,
|
||||
secret_service,
|
||||
stats_service,
|
||||
update_service,
|
||||
volume_service,
|
||||
@@ -64,7 +66,7 @@ def _map_docker(exc: DockerError):
|
||||
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
||||
raise exc # falls through to the global 502 DockerError handler
|
||||
|
||||
AGENT_VERSION = "0.28.0"
|
||||
AGENT_VERSION = "0.29.0"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -375,6 +377,77 @@ async def stack_updates(stack_id: str, refresh: bool = Query(True)) -> dict:
|
||||
return await update_service.stack_updates(stack_id, refresh=refresh)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Secrets & configs (per-stack, file-based)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class SecretWriteBody(BaseModel):
|
||||
kind: str = "secret"
|
||||
name: str
|
||||
content: str
|
||||
|
||||
|
||||
class SecretAttachBody(BaseModel):
|
||||
kind: str = "secret"
|
||||
name: str
|
||||
service: str
|
||||
target: str | None = None
|
||||
|
||||
|
||||
class SecretDetachBody(BaseModel):
|
||||
kind: str = "secret"
|
||||
name: str
|
||||
service: str
|
||||
|
||||
|
||||
def _ensure_stack(stack_id: str) -> None:
|
||||
if not os.path.isdir(compose_service.stack_dir(stack_id)):
|
||||
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
|
||||
|
||||
|
||||
def _secret_guard(fn, *args, **kwargs):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except (secret_service.SecretError, compose_edit_service.EditError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/agent/stacks/{stack_id}/secrets", dependencies=[Depends(verify_token)])
|
||||
def agent_list_secrets(stack_id: str) -> list:
|
||||
_ensure_stack(stack_id)
|
||||
return secret_service.list_all(stack_id)
|
||||
|
||||
|
||||
@app.put("/agent/stacks/{stack_id}/secrets", dependencies=[Depends(verify_token)])
|
||||
def agent_write_secret(stack_id: str, body: SecretWriteBody) -> dict:
|
||||
_ensure_stack(stack_id)
|
||||
return _secret_guard(secret_service.write_secret, stack_id, body.kind, body.name, body.content)
|
||||
|
||||
|
||||
@app.delete("/agent/stacks/{stack_id}/secrets/{kind}/{name}", dependencies=[Depends(verify_token)])
|
||||
def agent_delete_secret(stack_id: str, kind: str, name: str) -> dict:
|
||||
_ensure_stack(stack_id)
|
||||
_secret_guard(secret_service.delete_secret, stack_id, kind, name)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/agent/stacks/{stack_id}/secrets/attach", dependencies=[Depends(verify_token)])
|
||||
def agent_attach_secret(stack_id: str, body: SecretAttachBody) -> dict:
|
||||
_ensure_stack(stack_id)
|
||||
if not secret_service.exists(stack_id, body.kind, body.name):
|
||||
raise HTTPException(status_code=404, detail="secret not found")
|
||||
new_yaml = _secret_guard(secret_service.attach, stack_id, body.kind, body.name, body.service, body.target)
|
||||
return {"ok": True, "yaml": new_yaml}
|
||||
|
||||
|
||||
@app.post("/agent/stacks/{stack_id}/secrets/detach", dependencies=[Depends(verify_token)])
|
||||
def agent_detach_secret(stack_id: str, body: SecretDetachBody) -> dict:
|
||||
_ensure_stack(stack_id)
|
||||
new_yaml = _secret_guard(secret_service.detach, stack_id, body.kind, body.name, body.service)
|
||||
return {"ok": True, "yaml": new_yaml}
|
||||
|
||||
|
||||
@app.get("/agent/stacks/{stack_id}/backup", dependencies=[Depends(verify_token)])
|
||||
async def backup_stack(
|
||||
stack_id: str,
|
||||
|
||||
+3
-1
@@ -26,6 +26,7 @@ from routers import (
|
||||
networks,
|
||||
ports,
|
||||
schedules,
|
||||
secrets,
|
||||
settings as settings_router,
|
||||
stacks,
|
||||
system,
|
||||
@@ -56,7 +57,7 @@ async def lifespan(app: FastAPI):
|
||||
schedule_task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title="StackPilot", version="0.28.0", lifespan=lifespan)
|
||||
app = FastAPI(title="StackPilot", version="0.29.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -77,6 +78,7 @@ async def docker_error_handler(_request: Request, exc: DockerError):
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(stacks.router)
|
||||
app.include_router(secrets.router)
|
||||
app.include_router(containers.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(volumes.router)
|
||||
|
||||
@@ -19,6 +19,7 @@ from models.stack import StackCreate, StackUpdate
|
||||
from models.user import User
|
||||
from routers.files import NameBody, RenameBody, TransferBody, WriteBody
|
||||
from routers.networks import ContainerRef, NetworkCreate
|
||||
from routers.secrets import AttachBody, DetachBody, SecretWrite
|
||||
from models.auto_update import AutoUpdateRead, AutoUpdateWrite
|
||||
from services import (
|
||||
agent_service,
|
||||
@@ -1039,3 +1040,92 @@ async def agent_run_auto_update(
|
||||
await auto_update_service.run_policy(session, policy)
|
||||
session.refresh(policy)
|
||||
return auto_update_service.to_read(session, policy, stack_id, agent_id)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Secrets & configs (proxied) — remote stacks
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@router.get("/{agent_id}/stacks/{stack_id}/secrets")
|
||||
async def agent_list_secrets(
|
||||
agent_id: int,
|
||||
stack_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(require_admin),
|
||||
) -> list:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
return await _proxy(session, agent, "GET", f"/agent/stacks/{stack_id}/secrets") or []
|
||||
|
||||
|
||||
@router.put("/{agent_id}/stacks/{stack_id}/secrets")
|
||||
async def agent_write_secret(
|
||||
agent_id: int,
|
||||
stack_id: str,
|
||||
body: SecretWrite,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
result = await _proxy(session, agent, "PUT", f"/agent/stacks/{stack_id}/secrets", json=body.model_dump())
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.secret.write",
|
||||
target=f"{agent.name}/{stack_id}/{body.kind}/{body.name}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{agent_id}/stacks/{stack_id}/secrets/{kind}/{name}")
|
||||
async def agent_delete_secret(
|
||||
agent_id: int,
|
||||
stack_id: str,
|
||||
kind: str,
|
||||
name: str,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
result = await _proxy(session, agent, "DELETE", f"/agent/stacks/{stack_id}/secrets/{kind}/{name}")
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.secret.delete",
|
||||
target=f"{agent.name}/{stack_id}/{kind}/{name}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{agent_id}/stacks/{stack_id}/secrets/attach")
|
||||
async def agent_attach_secret(
|
||||
agent_id: int,
|
||||
stack_id: str,
|
||||
body: AttachBody,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
result = await _proxy(session, agent, "POST", f"/agent/stacks/{stack_id}/secrets/attach", json=body.model_dump())
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.secret.attach",
|
||||
target=f"{agent.name}/{stack_id}/{body.kind}/{body.name}->{body.service}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{agent_id}/stacks/{stack_id}/secrets/detach")
|
||||
async def agent_detach_secret(
|
||||
agent_id: int,
|
||||
stack_id: str,
|
||||
body: DetachBody,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
result = await _proxy(session, agent, "POST", f"/agent/stacks/{stack_id}/secrets/detach", json=body.model_dump())
|
||||
audit_service.record(
|
||||
session, user=user.username, action="agent.secret.detach",
|
||||
target=f"{agent.name}/{stack_id}/{body.kind}/{body.name}->{body.service}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Per-stack file-based secrets & configs (compose) — local host.
|
||||
|
||||
CRUD over the secret/config files stored in the stack directory, plus
|
||||
attach/detach which rewrite the stack's compose file to reference (or stop
|
||||
referencing) a secret/config from a service. A redeploy is required for changes
|
||||
to take effect on running containers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session
|
||||
|
||||
from auth import require_admin
|
||||
from database import get_session
|
||||
from models.user import User
|
||||
from services import audit_service, secret_service
|
||||
from services.compose_edit_service import EditError
|
||||
from services.secret_service import SecretError
|
||||
|
||||
router = APIRouter(prefix="/api/stacks/{stack_id}/secrets", tags=["secrets"])
|
||||
|
||||
|
||||
class SecretWrite(BaseModel):
|
||||
kind: str = "secret" # "secret" | "config"
|
||||
name: str
|
||||
content: str
|
||||
|
||||
|
||||
class AttachBody(BaseModel):
|
||||
kind: str = "secret"
|
||||
name: str
|
||||
service: str
|
||||
target: str | None = None # required for configs (mount path)
|
||||
|
||||
|
||||
class DetachBody(BaseModel):
|
||||
kind: str = "secret"
|
||||
name: str
|
||||
service: str
|
||||
|
||||
|
||||
def _ip(request: Request) -> str:
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _guard(fn, *args, **kwargs):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except (SecretError, EditError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
# --- These functions are shared verbatim by the agent (see agent_app.py). ---
|
||||
|
||||
|
||||
def list_secrets(stack_id: str) -> list[dict]:
|
||||
return secret_service.list_all(stack_id)
|
||||
|
||||
|
||||
def write_secret(stack_id: str, body: SecretWrite) -> dict:
|
||||
return _guard(secret_service.write_secret, stack_id, body.kind, body.name, body.content)
|
||||
|
||||
|
||||
def delete_secret(stack_id: str, kind: str, name: str) -> dict:
|
||||
_guard(secret_service.delete_secret, stack_id, kind, name)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def attach_secret(stack_id: str, body: AttachBody) -> dict:
|
||||
if not secret_service.exists(stack_id, body.kind, body.name):
|
||||
raise HTTPException(status_code=404, detail="secret not found")
|
||||
new_yaml = _guard(secret_service.attach, stack_id, body.kind, body.name, body.service, body.target)
|
||||
return {"ok": True, "yaml": new_yaml}
|
||||
|
||||
|
||||
def detach_secret(stack_id: str, body: DetachBody) -> dict:
|
||||
new_yaml = _guard(secret_service.detach, stack_id, body.kind, body.name, body.service)
|
||||
return {"ok": True, "yaml": new_yaml}
|
||||
|
||||
|
||||
# --- HTTP endpoints (admin only; secrets are sensitive) ---
|
||||
|
||||
|
||||
@router.get("")
|
||||
def http_list(stack_id: str, _user: User = Depends(require_admin)) -> list[dict]:
|
||||
return list_secrets(stack_id)
|
||||
|
||||
|
||||
@router.put("")
|
||||
def http_write(
|
||||
stack_id: str, body: SecretWrite, request: Request,
|
||||
session: Session = Depends(get_session), user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
result = write_secret(stack_id, body)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="secret.write",
|
||||
target=f"{stack_id}/{body.kind}/{body.name}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{kind}/{name}")
|
||||
def http_delete(
|
||||
stack_id: str, kind: str, name: str, request: Request,
|
||||
session: Session = Depends(get_session), user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
result = delete_secret(stack_id, kind, name)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="secret.delete",
|
||||
target=f"{stack_id}/{kind}/{name}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/attach")
|
||||
def http_attach(
|
||||
stack_id: str, body: AttachBody, request: Request,
|
||||
session: Session = Depends(get_session), user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
result = attach_secret(stack_id, body)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="secret.attach",
|
||||
target=f"{stack_id}/{body.kind}/{body.name}->{body.service}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/detach")
|
||||
def http_detach(
|
||||
stack_id: str, body: DetachBody, request: Request,
|
||||
session: Session = Depends(get_session), user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
result = detach_secret(stack_id, body)
|
||||
audit_service.record(
|
||||
session, user=user.username, action="secret.detach",
|
||||
target=f"{stack_id}/{body.kind}/{body.name}->{body.service}", ip=_ip(request),
|
||||
)
|
||||
return result
|
||||
@@ -32,8 +32,9 @@ def _dump(data: dict) -> str:
|
||||
# Drop empty top-level keys we may have created.
|
||||
if not data.get("services"):
|
||||
data.pop("services", None)
|
||||
if "volumes" in data and not data["volumes"]:
|
||||
data.pop("volumes", None)
|
||||
for key in ("volumes", "secrets", "configs"):
|
||||
if key in data and not data[key]:
|
||||
data.pop(key, None)
|
||||
return yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
|
||||
|
||||
|
||||
@@ -208,3 +209,89 @@ def set_resources(
|
||||
def list_services(yaml_str: str) -> list[str]:
|
||||
data = _load(yaml_str)
|
||||
return list(data["services"].keys())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Secrets & configs (compose file-based)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def add_secret(yaml_str: str, service: str, name: str, file_path: str) -> str:
|
||||
"""Define a top-level file-based secret and attach it to ``service``."""
|
||||
data = _load(yaml_str)
|
||||
svc = _get_service(data, service)
|
||||
secrets = data.setdefault("secrets", {})
|
||||
if not isinstance(secrets, dict):
|
||||
raise EditError("`secrets` must be a mapping")
|
||||
secrets[name] = {"file": file_path}
|
||||
refs = svc.setdefault("secrets", [])
|
||||
if not isinstance(refs, list):
|
||||
raise EditError(f"Service '{service}' secrets must be a list")
|
||||
if name not in refs:
|
||||
refs.append(name)
|
||||
return _dump(data)
|
||||
|
||||
|
||||
def remove_secret(yaml_str: str, service: str, name: str) -> str:
|
||||
"""Detach a secret from ``service``; drop the top-level def if now unused."""
|
||||
data = _load(yaml_str)
|
||||
svc = data["services"].get(service)
|
||||
if isinstance(svc, dict) and isinstance(svc.get("secrets"), list):
|
||||
svc["secrets"] = [s for s in svc["secrets"] if s != name]
|
||||
if not svc["secrets"]:
|
||||
svc.pop("secrets", None)
|
||||
_prune_top_level(data, "secrets", name, _secret_still_used)
|
||||
return _dump(data)
|
||||
|
||||
|
||||
def add_config(yaml_str: str, service: str, name: str, file_path: str, target: str) -> str:
|
||||
"""Define a top-level file-based config and mount it into ``service`` at target."""
|
||||
data = _load(yaml_str)
|
||||
svc = _get_service(data, service)
|
||||
configs = data.setdefault("configs", {})
|
||||
if not isinstance(configs, dict):
|
||||
raise EditError("`configs` must be a mapping")
|
||||
configs[name] = {"file": file_path}
|
||||
refs = svc.setdefault("configs", [])
|
||||
if not isinstance(refs, list):
|
||||
raise EditError(f"Service '{service}' configs must be a list")
|
||||
if not any(isinstance(e, dict) and e.get("source") == name for e in refs):
|
||||
refs.append({"source": name, "target": target})
|
||||
return _dump(data)
|
||||
|
||||
|
||||
def remove_config(yaml_str: str, service: str, name: str) -> str:
|
||||
data = _load(yaml_str)
|
||||
svc = data["services"].get(service)
|
||||
if isinstance(svc, dict) and isinstance(svc.get("configs"), list):
|
||||
svc["configs"] = [
|
||||
e for e in svc["configs"]
|
||||
if not (e == name or (isinstance(e, dict) and e.get("source") == name))
|
||||
]
|
||||
if not svc["configs"]:
|
||||
svc.pop("configs", None)
|
||||
_prune_top_level(data, "configs", name, _config_still_used)
|
||||
return _dump(data)
|
||||
|
||||
|
||||
def _secret_still_used(data: dict, name: str) -> bool:
|
||||
for svc in data.get("services", {}).values():
|
||||
if isinstance(svc, dict) and name in (svc.get("secrets") or []):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _config_still_used(data: dict, name: str) -> bool:
|
||||
for svc in data.get("services", {}).values():
|
||||
if not isinstance(svc, dict):
|
||||
continue
|
||||
for entry in svc.get("configs") or []:
|
||||
if entry == name or (isinstance(entry, dict) and entry.get("source") == name):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _prune_top_level(data: dict, key: str, name: str, still_used) -> None:
|
||||
top = data.get(key)
|
||||
if isinstance(top, dict) and name in top and not still_used(data, name):
|
||||
top.pop(name, None)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Per-stack, file-based compose secrets & configs.
|
||||
|
||||
Secret/config payloads are stored as files INSIDE the stack's own directory
|
||||
(``<stack_dir>/.secrets/<name>`` and ``.configs/<name>``) and referenced from
|
||||
the compose file with a RELATIVE path (``file: ./.secrets/<name>``). Because
|
||||
compose resolves ``file:`` relative to the compose file — which lives in the
|
||||
stack dir (a host bind-mount) — the Docker daemon reads the file correctly with
|
||||
no ``HOST_ROOT_PREFIX`` dependency, exactly as if the user had dropped a secret
|
||||
file next to their ``compose.yaml`` by hand.
|
||||
|
||||
Content is never returned by the listing API; only metadata (name, size, mtime).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from services import compose_edit_service, compose_service
|
||||
|
||||
KIND_DIR = {"secret": ".secrets", "config": ".configs"}
|
||||
_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
MAX_SECRET_BYTES = 1 * 1024 * 1024 # 1 MiB
|
||||
|
||||
|
||||
class SecretError(Exception):
|
||||
"""Invalid secret request (bad kind/name, too large)."""
|
||||
|
||||
|
||||
def _check_kind(kind: str) -> str:
|
||||
if kind not in KIND_DIR:
|
||||
raise SecretError(f"unknown kind '{kind}'")
|
||||
return kind
|
||||
|
||||
|
||||
def _check_name(name: str) -> str:
|
||||
# A single path component, no traversal, no leading dot (keeps it out of the
|
||||
# way of .secrets/.configs themselves and hidden-file surprises).
|
||||
if not name or name.startswith(".") or not _NAME_RE.match(name) or os.path.sep in name:
|
||||
raise SecretError("name must match [A-Za-z0-9._-] and not start with a dot")
|
||||
return name
|
||||
|
||||
|
||||
def kind_dir(stack_id: str, kind: str, override: Optional[str] = None) -> str:
|
||||
_check_kind(kind)
|
||||
return os.path.join(compose_service.stack_dir(stack_id, override), KIND_DIR[kind])
|
||||
|
||||
|
||||
def _path(stack_id: str, kind: str, name: str, override: Optional[str] = None) -> str:
|
||||
return os.path.join(kind_dir(stack_id, kind, override), _check_name(name))
|
||||
|
||||
|
||||
def rel_path(kind: str, name: str) -> str:
|
||||
"""Path to put in the compose ``file:`` field (relative to the compose file)."""
|
||||
return f"./{KIND_DIR[_check_kind(kind)]}/{_check_name(name)}"
|
||||
|
||||
|
||||
def list_secrets(stack_id: str, kind: str, override: Optional[str] = None) -> list[dict]:
|
||||
directory = kind_dir(stack_id, kind, override)
|
||||
if not os.path.isdir(directory):
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for name in sorted(os.listdir(directory)):
|
||||
full = os.path.join(directory, name)
|
||||
if os.path.isfile(full):
|
||||
st = os.stat(full)
|
||||
out.append({"name": name, "kind": kind, "size": st.st_size, "modified": st.st_mtime})
|
||||
return out
|
||||
|
||||
|
||||
def list_all(stack_id: str, override: Optional[str] = None) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
for kind in KIND_DIR:
|
||||
items.extend(list_secrets(stack_id, kind, override))
|
||||
return items
|
||||
|
||||
|
||||
def write_secret(stack_id: str, kind: str, name: str, content: str, override: Optional[str] = None) -> dict:
|
||||
_check_kind(kind)
|
||||
_check_name(name)
|
||||
data = content.encode("utf-8")
|
||||
if len(data) > MAX_SECRET_BYTES:
|
||||
raise SecretError("content exceeds 1 MiB limit")
|
||||
directory = kind_dir(stack_id, kind, override)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
os.chmod(directory, 0o700)
|
||||
path = _path(stack_id, kind, name, override)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(data)
|
||||
os.chmod(path, 0o600)
|
||||
return {"name": name, "kind": kind, "size": len(data)}
|
||||
|
||||
|
||||
def delete_secret(stack_id: str, kind: str, name: str, override: Optional[str] = None) -> None:
|
||||
path = _path(stack_id, kind, name, override)
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
|
||||
|
||||
def exists(stack_id: str, kind: str, name: str, override: Optional[str] = None) -> bool:
|
||||
return os.path.isfile(_path(stack_id, kind, name, override))
|
||||
|
||||
|
||||
def attach(stack_id: str, kind: str, name: str, service: str,
|
||||
target: Optional[str] = None, override: Optional[str] = None) -> str:
|
||||
"""Reference a stored secret/config from ``service`` in the stack's compose
|
||||
file (relative ``file:`` path) and persist it. Returns the new YAML."""
|
||||
_check_kind(kind)
|
||||
_check_name(name)
|
||||
yaml_str = compose_service.read_compose(stack_id, override)
|
||||
file_path = rel_path(kind, name)
|
||||
if kind == "config":
|
||||
new_yaml = compose_edit_service.add_config(yaml_str, service, name, file_path, target or f"/{name}")
|
||||
else:
|
||||
new_yaml = compose_edit_service.add_secret(yaml_str, service, name, file_path)
|
||||
compose_service.write_compose(stack_id, new_yaml, override)
|
||||
return new_yaml
|
||||
|
||||
|
||||
def detach(stack_id: str, kind: str, name: str, service: str, override: Optional[str] = None) -> str:
|
||||
_check_kind(kind)
|
||||
yaml_str = compose_service.read_compose(stack_id, override)
|
||||
if kind == "config":
|
||||
new_yaml = compose_edit_service.remove_config(yaml_str, service, name)
|
||||
else:
|
||||
new_yaml = compose_edit_service.remove_secret(yaml_str, service, name)
|
||||
compose_service.write_compose(stack_id, new_yaml, override)
|
||||
return new_yaml
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.28.0",
|
||||
"version": "0.29.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import api from "./client";
|
||||
|
||||
export type SecretKind = "secret" | "config";
|
||||
|
||||
export interface SecretEntry {
|
||||
name: string;
|
||||
kind: SecretKind;
|
||||
size: number;
|
||||
modified: number;
|
||||
}
|
||||
|
||||
// Local host, or a remote agent's stack when agentId is given.
|
||||
const base = (stackId: string, agentId?: number) =>
|
||||
agentId != null
|
||||
? `/api/agents/${agentId}/stacks/${stackId}/secrets`
|
||||
: `/api/stacks/${stackId}/secrets`;
|
||||
|
||||
export const secretsApi = {
|
||||
list: (stackId: string, agentId?: number) =>
|
||||
api.get<SecretEntry[]>(base(stackId, agentId)).then((r) => r.data),
|
||||
write: (
|
||||
stackId: string,
|
||||
body: { kind: SecretKind; name: string; content: string },
|
||||
agentId?: number,
|
||||
) => api.put(base(stackId, agentId), body).then((r) => r.data),
|
||||
remove: (stackId: string, kind: SecretKind, name: string, agentId?: number) =>
|
||||
api.delete(`${base(stackId, agentId)}/${kind}/${name}`).then((r) => r.data),
|
||||
attach: (
|
||||
stackId: string,
|
||||
body: { kind: SecretKind; name: string; service: string; target?: string },
|
||||
agentId?: number,
|
||||
) => api.post(`${base(stackId, agentId)}/attach`, body).then((r) => r.data),
|
||||
detach: (
|
||||
stackId: string,
|
||||
body: { kind: SecretKind; name: string; service: string },
|
||||
agentId?: number,
|
||||
) => api.post(`${base(stackId, agentId)}/detach`, body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Trash2, Link2, FileCog } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge, Button, Card, Input } from "@/components/ui";
|
||||
import { secretsApi, type SecretEntry, type SecretKind } from "@/api/secrets";
|
||||
import { editorApi } from "@/api/editor";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
|
||||
/**
|
||||
* Per-stack file-based secrets & configs. Create stores a file in the stack
|
||||
* dir; attach references it from a service in the compose file (redeploy to
|
||||
* apply). Admin-only on the backend.
|
||||
*/
|
||||
export function SecretsPanel({
|
||||
stackId,
|
||||
yaml,
|
||||
agentId,
|
||||
isAdmin,
|
||||
onChanged,
|
||||
}: {
|
||||
stackId: string;
|
||||
yaml: string;
|
||||
agentId?: number;
|
||||
isAdmin: boolean;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const key = ["secrets", agentId ?? "local", stackId];
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => secretsApi.list(stackId, agentId),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
const services = useQuery({
|
||||
queryKey: ["editor-services", stackId, agentId, yaml.length],
|
||||
queryFn: () => editorApi.services(yaml),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
const [kind, setKind] = useState<SecretKind>("secret");
|
||||
const [name, setName] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: key });
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => secretsApi.write(stackId, { kind, name: name.trim(), content }, agentId),
|
||||
onSuccess: () => {
|
||||
toast.success(`${kind} "${name}" saved`);
|
||||
setName(""); setContent("");
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (s: SecretEntry) => secretsApi.remove(stackId, s.kind, s.name, agentId),
|
||||
onSuccess: () => { toast.success("Deleted"); invalidate(); },
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
|
||||
const attach = useMutation({
|
||||
mutationFn: (v: { s: SecretEntry; service: string; target?: string }) =>
|
||||
secretsApi.attach(stackId, { kind: v.s.kind, name: v.s.name, service: v.service, target: v.target }, agentId),
|
||||
onSuccess: () => { toast.success("Attached — redeploy the stack to apply"); onChanged?.(); },
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
|
||||
const detach = useMutation({
|
||||
mutationFn: (v: { s: SecretEntry; service: string }) =>
|
||||
secretsApi.detach(stackId, { kind: v.s.kind, name: v.s.name, service: v.service }, agentId),
|
||||
onSuccess: () => { toast.success("Detached — redeploy the stack to apply"); onChanged?.(); },
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
|
||||
if (!isAdmin)
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-sm text-slate-500">Secrets are visible to admins only.</p>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const items = list.data ?? [];
|
||||
const svcList = services.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 overflow-auto">
|
||||
<Card className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-accent dark:text-accent-dark" />
|
||||
<span className="font-medium">New secret / config</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-slate-500">Type</span>
|
||||
<select
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value as SecretKind)}
|
||||
className="rounded-md border border-slate-300 bg-transparent px-2 py-2 text-sm dark:border-slate-600"
|
||||
>
|
||||
<option value="secret">Secret (/run/secrets)</option>
|
||||
<option value="config">Config (mounted file)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-slate-500">Name</span>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="db_password" />
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="secret content…"
|
||||
className="w-full rounded-md border border-slate-300 bg-transparent p-2 font-mono text-sm dark:border-slate-600"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => create.mutate()}
|
||||
loading={create.isPending}
|
||||
disabled={!name.trim() || !content}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Card className="p-0">
|
||||
{items.length === 0 ? (
|
||||
<p className="p-4 text-sm text-slate-500">No secrets or configs yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{items.map((s) => (
|
||||
<SecretRow
|
||||
key={`${s.kind}/${s.name}`}
|
||||
s={s}
|
||||
services={svcList}
|
||||
onDelete={() => remove.mutate(s)}
|
||||
onAttach={(service, target) => attach.mutate({ s, service, target })}
|
||||
onDetach={(service) => detach.mutate({ s, service })}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SecretRow({
|
||||
s,
|
||||
services,
|
||||
onDelete,
|
||||
onAttach,
|
||||
onDetach,
|
||||
}: {
|
||||
s: SecretEntry;
|
||||
services: string[];
|
||||
onDelete: () => void;
|
||||
onAttach: (service: string, target?: string) => void;
|
||||
onDetach: (service: string) => void;
|
||||
}) {
|
||||
const [service, setService] = useState(services[0] ?? "");
|
||||
const [target, setTarget] = useState("");
|
||||
|
||||
return (
|
||||
<li className="flex flex-wrap items-center gap-3 p-3">
|
||||
{s.kind === "config" ? (
|
||||
<FileCog className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
) : (
|
||||
<KeyRound className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<span className="font-mono">{s.name}</span>
|
||||
<Badge>{s.kind}</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">{s.size} B</span>
|
||||
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={service}
|
||||
onChange={(e) => setService(e.target.value)}
|
||||
className="rounded-md border border-slate-300 bg-transparent px-2 py-1 text-sm dark:border-slate-600"
|
||||
>
|
||||
{services.length === 0 && <option value="">no services</option>}
|
||||
{services.map((sv) => (
|
||||
<option key={sv} value={sv}>{sv}</option>
|
||||
))}
|
||||
</select>
|
||||
{s.kind === "config" && (
|
||||
<Input
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
placeholder="/etc/app.conf"
|
||||
className="w-40"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-2 py-1"
|
||||
disabled={!service || (s.kind === "config" && !target)}
|
||||
onClick={() => onAttach(service, target || undefined)}
|
||||
title="Attach to service"
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-2 py-1"
|
||||
disabled={!service}
|
||||
onClick={() => onDetach(service)}
|
||||
title="Detach from service"
|
||||
>
|
||||
detach
|
||||
</Button>
|
||||
<Button variant="outline" className="px-2 py-1" onClick={onDelete} title="Delete file">
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -17,13 +17,14 @@ import { HostDot } from "@/components/hosts/HostDot";
|
||||
import { LogViewer } from "@/components/stacks/LogViewer";
|
||||
import { ContainerCard } from "@/components/stacks/ContainerCard";
|
||||
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
|
||||
import { SecretsPanel } from "@/components/stacks/SecretsPanel";
|
||||
import { BackupButton } from "@/components/stacks/BackupRestore";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import type { ContainerInfo } from "@/types";
|
||||
|
||||
const TABS = ["Overview", "Logs", "Environment", "Compose"] as const;
|
||||
const TABS = ["Overview", "Logs", "Environment", "Compose", "Secrets"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
export function RemoteStackDetail() {
|
||||
@@ -166,6 +167,15 @@ export function RemoteStackDetail() {
|
||||
queryKey={["agent-stack", aid, id]}
|
||||
/>
|
||||
)}
|
||||
{tab === "Secrets" && (
|
||||
<SecretsPanel
|
||||
stackId={id}
|
||||
yaml={data.yaml}
|
||||
agentId={aid}
|
||||
isAdmin={isAdmin}
|
||||
onChanged={() => qc.invalidateQueries({ queryKey: ["agent-stack", aid, id] })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||
import { LogViewer } from "@/components/stacks/LogViewer";
|
||||
import { ContainerCard } from "@/components/stacks/ContainerCard";
|
||||
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
|
||||
import { SecretsPanel } from "@/components/stacks/SecretsPanel";
|
||||
import { BackupButton } from "@/components/stacks/BackupRestore";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
@@ -24,7 +25,7 @@ import { useAuthStore } from "@/store/auth";
|
||||
import { useStackActions } from "@/hooks/useStackActions";
|
||||
import type { ContainerInfo } from "@/types";
|
||||
|
||||
const TABS = ["Overview", "Logs", "Environment", "Compose"] as const;
|
||||
const TABS = ["Overview", "Logs", "Environment", "Compose", "Secrets"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
export function StackDetail() {
|
||||
@@ -115,6 +116,14 @@ export function StackDetail() {
|
||||
{tab === "Logs" && <LogViewer stackId={id} />}
|
||||
{tab === "Environment" && <EnvView env={data.env} />}
|
||||
{tab === "Compose" && <ComposeView yaml={data.yaml} />}
|
||||
{tab === "Secrets" && (
|
||||
<SecretsPanel
|
||||
stackId={id}
|
||||
yaml={data.yaml}
|
||||
isAdmin={isAdmin}
|
||||
onChanged={() => queryClient.invalidateQueries({ queryKey: ["stack", id] })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user