Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
169 lines
5.5 KiB
Go
169 lines
5.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
|
"github.com/docker/docker/api/types/container"
|
|
"github.com/docker/docker/pkg/stdcopy"
|
|
)
|
|
|
|
// maxExecOutput limits container exec output to 5MB to prevent OOM.
|
|
const maxExecOutput = 5 * 1024 * 1024
|
|
|
|
// findContainer finds a running container for the workspace.
|
|
// Checks provisioner name, full ID, and DB workspace name (same candidates as terminal handler).
|
|
func (h *TemplatesHandler) findContainer(ctx context.Context, workspaceID string) string {
|
|
if h.docker == nil {
|
|
return ""
|
|
}
|
|
name := provisioner.ContainerName(workspaceID)
|
|
candidates := []string{name}
|
|
if name != "ws-"+workspaceID {
|
|
candidates = append(candidates, "ws-"+workspaceID)
|
|
}
|
|
// Also check by workspace name from DB
|
|
var wsName string
|
|
db.DB.QueryRowContext(ctx, `SELECT LOWER(REPLACE(name, ' ', '-')) FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
|
|
if wsName != "" {
|
|
candidates = append(candidates, wsName)
|
|
}
|
|
for _, c := range candidates {
|
|
info, err := h.docker.ContainerInspect(ctx, c)
|
|
if err == nil && info.State.Running {
|
|
return c
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// execInContainer runs a command in a container and returns stdout (capped at maxExecOutput).
|
|
func (h *TemplatesHandler) execInContainer(ctx context.Context, containerName string, cmd []string) (string, error) {
|
|
execCfg := container.ExecOptions{
|
|
Cmd: cmd,
|
|
AttachStdout: true,
|
|
AttachStderr: true,
|
|
}
|
|
execID, err := h.docker.ContainerExecCreate(ctx, containerName, execCfg)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
resp, err := h.docker.ContainerExecAttach(ctx, execID.ID, container.ExecAttachOptions{})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Close()
|
|
var stdout bytes.Buffer
|
|
// Use stdcopy to correctly demux Docker multiplexed stream (stdout/stderr)
|
|
stdcopy.StdCopy(&stdout, io.Discard, io.LimitReader(resp.Reader, maxExecOutput))
|
|
return strings.TrimSpace(stdout.String()), nil
|
|
}
|
|
|
|
// copyFilesToContainer creates a tar archive from a map of files and copies it into a container.
|
|
func (h *TemplatesHandler) copyFilesToContainer(ctx context.Context, containerName, destPath string, files map[string]string) error {
|
|
var buf bytes.Buffer
|
|
tw := tar.NewWriter(&buf)
|
|
|
|
createdDirs := map[string]bool{}
|
|
for name, content := range files {
|
|
// Create parent directories in tar (deduplicated)
|
|
dir := filepath.Dir(name)
|
|
if dir != "." && !createdDirs[dir] {
|
|
tw.WriteHeader(&tar.Header{
|
|
Typeflag: tar.TypeDir,
|
|
Name: dir + "/",
|
|
Mode: 0755,
|
|
})
|
|
createdDirs[dir] = true
|
|
}
|
|
|
|
data := []byte(content)
|
|
header := &tar.Header{
|
|
Name: name,
|
|
Mode: 0644,
|
|
Size: int64(len(data)),
|
|
}
|
|
if err := tw.WriteHeader(header); err != nil {
|
|
return fmt.Errorf("failed to write tar header for %s: %w", name, err)
|
|
}
|
|
if _, err := tw.Write(data); err != nil {
|
|
return fmt.Errorf("failed to write tar data for %s: %w", name, err)
|
|
}
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
return fmt.Errorf("failed to close tar writer: %w", err)
|
|
}
|
|
|
|
return h.docker.CopyToContainer(ctx, containerName, destPath, &buf, container.CopyToContainerOptions{})
|
|
}
|
|
|
|
// writeViaEphemeral writes files to a named volume using an ephemeral Alpine container.
|
|
// Used when the workspace container is offline (e.g., during provisioning).
|
|
func (h *TemplatesHandler) writeViaEphemeral(ctx context.Context, volumeName string, files map[string]string) error {
|
|
if h.docker == nil {
|
|
return fmt.Errorf("docker not available")
|
|
}
|
|
|
|
// Create ephemeral container mounting the volume
|
|
resp, err := h.docker.ContainerCreate(ctx, &container.Config{
|
|
Image: "alpine:latest",
|
|
Cmd: []string{"sleep", "10"},
|
|
}, &container.HostConfig{
|
|
Binds: []string{volumeName + ":/configs"},
|
|
}, nil, nil, "")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create ephemeral container: %w", err)
|
|
}
|
|
defer h.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true})
|
|
|
|
if err := h.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
|
return fmt.Errorf("failed to start ephemeral container: %w", err)
|
|
}
|
|
|
|
// Copy files via tar, then stop container cleanly
|
|
if err := h.copyFilesToContainer(ctx, resp.ID, "/configs", files); err != nil {
|
|
return err
|
|
}
|
|
// Wait for container to be ready for removal (copy is synchronous, but be safe)
|
|
timeout := 5
|
|
h.docker.ContainerStop(ctx, resp.ID, container.StopOptions{Timeout: &timeout})
|
|
return nil
|
|
}
|
|
|
|
// deleteViaEphemeral deletes a file from a named volume using an ephemeral container.
|
|
func (h *TemplatesHandler) deleteViaEphemeral(ctx context.Context, volumeName, filePath string) error {
|
|
if h.docker == nil {
|
|
return fmt.Errorf("docker not available")
|
|
}
|
|
|
|
resp, err := h.docker.ContainerCreate(ctx, &container.Config{
|
|
Image: "alpine:latest",
|
|
Cmd: []string{"rm", "-rf", "/configs/" + filePath},
|
|
}, &container.HostConfig{
|
|
Binds: []string{volumeName + ":/configs"},
|
|
}, nil, nil, "")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create ephemeral container: %w", err)
|
|
}
|
|
defer h.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true})
|
|
|
|
if err := h.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
|
return err
|
|
}
|
|
// Wait for the rm command to finish before removing the container
|
|
statusCh, errCh := h.docker.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning)
|
|
select {
|
|
case <-statusCh:
|
|
return nil
|
|
case err := <-errCh:
|
|
return err
|
|
}
|
|
}
|