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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Keep the Docker build context small. The build needs api/ + web/ source and
# the CVS Python package (cvs/, setup.py, requirements.txt, version.txt) for the
# runtime CLI; caches and venvs below are excluded.
.git
**/node_modules
web/dist
api/internal/webui/dist/assets
**/__pycache__
**/*.pyc
.cvs_venv
.ruff_venv
.test_venv
.pytest_cache
.ruff_cache
dist
cvs.egg-info
*.tgz
*.log
LOG
LOGS
78 changes: 78 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# syntax=docker/dockerfile:1
#
# CVS Unified Platform daemon - single image serving all three tiles.
#
# Multi-stage build (mirrors the pattern of the existing cluster-mon and
# fleet-manager Dockerfiles, swapping the Python backend for a Go binary):
# 1. node stage builds the React UI bundle
# 2. go stage compiles the daemon with the UI embedded via go:embed
# 3. minimal runtime stage ships just the static binary
#
# The runtime stage ships the Go daemon together with the CVS Python CLI so the
# Test Execution tile can shell out to `cvs list --json` (the authoritative
# source of test suites). openssh-client is included for later SSH slices.

# ---- Stage 1: build the React UI ----
FROM node:20-alpine AS web-builder
WORKDIR /web
COPY web/package.json web/package-lock.json* ./
RUN npm ci
COPY web/ ./
RUN npm run build

# ---- Stage 2: build the Go daemon with the embedded UI ----
FROM golang:1.25-alpine AS go-builder
WORKDIR /src
COPY api/go.mod api/go.sum ./
RUN go mod download
COPY api/ ./
# Replace the committed placeholder bundle with the freshly built UI.
RUN rm -rf internal/webui/dist
COPY --from=web-builder /web/dist ./internal/webui/dist
ARG VERSION=0.0.0-dev
ARG COMMIT=unknown
ARG BUILD_TIME=unknown
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w \
-X github.com/ROCm/cvs/api/internal/version.Version=${VERSION} \
-X github.com/ROCm/cvs/api/internal/version.Commit=${COMMIT} \
-X github.com/ROCm/cvs/api/internal/version.BuildTime=${BUILD_TIME}" \
-o /out/cvs-server ./cmd/server

# ---- Stage 3: runtime (Go daemon + CVS CLI) ----
FROM python:3.12-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates openssh-client \
&& rm -rf /var/lib/apt/lists/*

# Install the CVS CLI so the daemon can shell out to `cvs list` / `cvs run`.
#
# Use an editable install and ship the repo-root pytest.ini so the in-container
# layout matches a normal venv install: the source tree stays at /opt/cvs/cvs
# and pytest.ini sits above it. This lets `cvs run` resolve pytest's rootdir to
# /opt/cvs and discover cvs/conftest.py (which registers --cluster_file /
# --config_file). A plain `pip install .` copies the package into site-packages
# without pytest.ini, so rootdir collapses to the test dir and those options are
# rejected.
WORKDIR /opt/cvs
COPY requirements.txt setup.py version.txt MANIFEST.in pytest.ini ./
COPY cvs/ ./cvs/
RUN pip install --no-cache-dir -e .

WORKDIR /app
COPY --from=go-builder /out/cvs-server /app/cvs-server
# Persistent data dir for FileStores + uploaded SSH keys. Must be writable by the
# non-root runtime user; a named volume is mounted here in docker-compose so the
# inventory and keys survive container restarts.
RUN useradd -u 10001 -m cvs \
&& mkdir -p /app/data/keys \
&& chown -R cvs:cvs /app/data
USER cvs
EXPOSE 8080
ENV CVS_LISTEN_ADDR=:8080 \
CVS_BIN=cvs \
CVS_CONFIG_DIR=/opt/cvs/cvs/input/config_file \
CVS_DATA_DIR=/app/data \
CVS_SSH_KEY_DIR=/app/data/keys
VOLUME ["/app/data"]
ENTRYPOINT ["/app/cvs-server"]
72 changes: 72 additions & 0 deletions api/cmd/server/adapters.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package main

import (
"os"

"github.com/ROCm/cvs/api/internal/cluster"
"github.com/ROCm/cvs/api/internal/clustermon"
"github.com/ROCm/cvs/api/internal/inventory"
"github.com/ROCm/cvs/api/internal/testexec"
"github.com/ROCm/cvs/api/internal/transport/ws"
)

// invAdapter bridges the inventory store + key store to the cluster package's
// InventoryProvider, resolving the uploaded key name to an absolute path for
// the `cvs generate cluster_json --key_file` flag.
type invAdapter struct {
store inventory.Store
keys *inventory.KeyStore
}

func (a invAdapter) Current() (cluster.Inventory, bool, error) {
inv, ok, err := a.store.Get()
if err != nil || !ok {
return cluster.Inventory{}, ok, err
}
keyFile := ""
if a.keys != nil && inv.KeyName != "" {
if p, e := a.keys.Path(inv.KeyName); e == nil {
keyFile = p
}
}
return cluster.Inventory{Username: inv.Username, KeyFile: keyFile, Nodes: inv.Nodes}, true, nil
}

// wsEvents adapts the WS hub to testexec.Events so the executor can stream live
// log lines and lifecycle transitions without importing the ws package.
type wsEvents struct{ hub *ws.Hub }

func (e wsEvents) Log(id, line string) { e.hub.PublishLog(id, line) }
func (e wsEvents) Status(id string, ex testexec.Execution) { e.hub.PublishStatus(id, ex) }
func (e wsEvents) Complete(ex testexec.Execution) { e.hub.PublishCompletion(ex.ID, ex) }

// metricsProvider adapts the Cluster Monitor metrics service to
// ws.MetricsProvider so a newly connected /ws/clustermon client gets the cached
// latest snapshot as its first frame.
type metricsProvider struct{ svc *clustermon.MetricsService }

func (m metricsProvider) LatestMetrics() (any, bool) {
snap := m.svc.Latest()
if snap == nil {
return nil, false
}
return snap, true
}

// execSnapshots adapts the execution store to ws.SnapshotProvider, supplying the
// terminal/late-joiner fallback (persisted log + final status).
type execSnapshots struct{ store testexec.ExecutionStore }

func (s execSnapshots) Snapshot(id string) (ws.ExecutionSnapshot, bool) {
ex, ok := s.store.Get(id)
if !ok {
return ws.ExecutionSnapshot{}, false
}
logs := ""
if ex.LogPath != "" {
if b, err := os.ReadFile(ex.LogPath); err == nil {
logs = string(b)
}
}
return ws.ExecutionSnapshot{Terminal: ex.Status.Terminal(), Logs: logs, Status: ex}, true
}
Loading
Loading