Files
run/runtime/workspace_seed.go
T

205 lines
7.6 KiB
Go

package runtime
import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"browser.local/run/config"
"browser.local/run/protocol"
)
type workspaceSeedFile struct {
Path string `json:"path"`
Content string `json:"content"`
Encoding string `json:"encoding,omitempty"`
Mode int `json:"mode,omitempty"`
}
// MaterializeWorkspaceSeed writes platform-packaged plugin assets into the
// scoped run workspace. The seed contains plugin-owned files only; run treats
// them as opaque generic lifecycle assets.
func MaterializeWorkspaceSeed(cfg config.Config) error {
startedAt := time.Now()
encoded := strings.TrimSpace(cfg.WorkspaceSeed)
if encoded == "" {
log.Printf("RUN phase=workspace_seed status=skipped reason=empty workspace=%s", safeOptional(cfg.WorkspaceRoot))
return nil
}
log.Printf("RUN phase=workspace_seed status=decoding workspace=%s encodedBytes=%d componentKey=%s", safeOptional(cfg.WorkspaceRoot), len(encoded), safeOptional(cfg.ComponentKey))
payload, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
log.Printf("RUN phase=workspace_seed status=decode_failed workspace=%s error=%s", safeOptional(cfg.WorkspaceRoot), err.Error())
return fmt.Errorf("decode workspace seed: %w", err)
}
var files []workspaceSeedFile
if err := json.Unmarshal(payload, &files); err != nil {
log.Printf("RUN phase=workspace_seed status=manifest_failed workspace=%s payloadBytes=%d error=%s", safeOptional(cfg.WorkspaceRoot), len(payload), err.Error())
return fmt.Errorf("decode workspace seed manifest: %w", err)
}
log.Printf("RUN phase=workspace_seed status=decoded workspace=%s payloadBytes=%d files=%d", safeOptional(cfg.WorkspaceRoot), len(payload), len(files))
if len(files) == 0 {
log.Printf("RUN phase=workspace_seed status=skipped reason=no_files workspace=%s durationMs=%d", safeOptional(cfg.WorkspaceRoot), time.Since(startedAt).Milliseconds())
return nil
}
if strings.TrimSpace(cfg.ServerInstanceID) == "" {
log.Printf("RUN phase=workspace_seed status=failed reason=missing_server workspace=%s", safeOptional(cfg.WorkspaceRoot))
return fmt.Errorf("workspace seed requires a server instance id")
}
scope, err := seededWorkspaceScopeForFiles(cfg, files)
if err != nil {
log.Printf("RUN phase=workspace_seed status=scope_failed workspace=%s server=%s componentKey=%s error=%s", safeOptional(cfg.WorkspaceRoot), safeOptional(cfg.ServerInstanceID), safeOptional(cfg.ComponentKey), err.Error())
return err
}
log.Printf("RUN phase=workspace_seed status=scope_ready workspace=%s scope=%s files=%d", safeOptional(cfg.WorkspaceRoot), safeOptional(scope), len(files))
totalBytes := 0
for index, file := range files {
written, err := writeWorkspaceSeedFile(scope, file, index+1, len(files))
if err != nil {
log.Printf("RUN phase=workspace_seed.file status=failed index=%d total=%d path=%s error=%s", index+1, len(files), safeOptional(file.Path), err.Error())
return err
}
totalBytes += written
}
log.Printf("RUN phase=workspace_seed status=complete workspace=%s scope=%s files=%d bytes=%d durationMs=%d", safeOptional(cfg.WorkspaceRoot), safeOptional(scope), len(files), totalBytes, time.Since(startedAt).Milliseconds())
return nil
}
func seededWorkspaceScope(cfg config.Config) (string, error) {
return seededWorkspaceScopeForFiles(cfg, nil)
}
func seededWorkspaceScopeForFiles(cfg config.Config, files []workspaceSeedFile) (string, error) {
if strings.TrimSpace(cfg.ComponentKey) != "" {
return NewWorkspaceResolver(cfg.WorkspaceRoot).Scope(cfg.ServerInstanceID, cfg.ComponentKey)
}
profileKey, err := workspaceSeedProfileKey(cfg, files)
if err != nil {
return "", err
}
if profileKey != "" {
return NewWorkspaceResolver(cfg.WorkspaceRoot).Scope(cfg.ServerInstanceID, profileKey)
}
return scopedServerWorkspace(cfg.WorkspaceRoot, cfg.ServerInstanceID)
}
func workspaceSeedProfileKey(cfg config.Config, files []workspaceSeedFile) (string, error) {
var err error
if len(files) == 0 && strings.TrimSpace(cfg.WorkspaceSeed) != "" {
files, err = decodeWorkspaceSeedFiles(cfg.WorkspaceSeed)
if err != nil {
return "", err
}
}
for _, file := range files {
if filepath.ToSlash(strings.TrimSpace(file.Path)) != autonomousLifecyclePlanKey {
continue
}
body, err := workspaceSeedFileContent(file)
if err != nil {
return "", err
}
var plan struct {
ProfileKey string `json:"profileKey,omitempty"`
}
if err := json.Unmarshal(body, &plan); err != nil {
return "", fmt.Errorf("decode workspace seed lifecycle profile: %w", err)
}
profileKey := strings.TrimSpace(plan.ProfileKey)
if profileKey == "" {
return "", nil
}
if !protocol.ValidLogicalFileKey(profileKey) {
return "", fmt.Errorf("workspace seed lifecycle profile is unsafe")
}
return profileKey, nil
}
return "", nil
}
func decodeWorkspaceSeedFiles(encoded string) ([]workspaceSeedFile, error) {
payload, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
if err != nil {
return nil, fmt.Errorf("decode workspace seed: %w", err)
}
var files []workspaceSeedFile
if err := json.Unmarshal(payload, &files); err != nil {
return nil, fmt.Errorf("decode workspace seed manifest: %w", err)
}
return files, nil
}
func writeWorkspaceSeedFile(scope string, file workspaceSeedFile, index int, total int) (int, error) {
target, err := workspaceSeedTarget(scope, file.Path)
if err != nil {
return 0, err
}
mode := os.FileMode(file.Mode)
if mode == 0 {
mode = 0o600
}
if mode&0o777 != mode || mode&0o022 != 0 {
return 0, fmt.Errorf("workspace seed file mode is unsafe")
}
body, err := workspaceSeedFileContent(file)
if err != nil {
return 0, err
}
log.Printf("RUN phase=workspace_seed.file status=writing index=%d total=%d path=%s target=%s bytes=%d mode=%#o", index, total, safeOptional(file.Path), safeOptional(target), len(body), mode)
if err := ensureDirectory(filepath.Dir(target)); err != nil {
return 0, err
}
log.Printf("RUN phase=workspace_seed.file status=directory_ready index=%d total=%d dir=%s", index, total, safeOptional(filepath.Dir(target)))
if err := os.WriteFile(target, body, mode); err != nil {
return 0, err
}
log.Printf("RUN phase=workspace_seed.file status=written index=%d total=%d path=%s target=%s bytes=%d mode=%#o", index, total, safeOptional(file.Path), safeOptional(target), len(body), mode)
return len(body), nil
}
func workspaceSeedFileContent(file workspaceSeedFile) ([]byte, error) {
switch strings.TrimSpace(file.Encoding) {
case "":
return []byte(file.Content), nil
case "base64":
body, err := base64.StdEncoding.DecodeString(strings.TrimSpace(file.Content))
if err != nil {
return nil, fmt.Errorf("workspace seed file content is not valid base64")
}
return body, nil
default:
return nil, fmt.Errorf("workspace seed file encoding is unsupported")
}
}
func workspaceSeedTarget(scope string, key string) (string, error) {
if strings.TrimSpace(scope) == "" {
return "", fmt.Errorf("workspace scope is invalid")
}
if !protocol.ValidLogicalFileKey(key) || filepath.IsAbs(key) || strings.Contains(key, `\`) {
return "", fmt.Errorf("workspace seed path is unsafe")
}
cleanScope, err := filepath.Abs(scope)
if err != nil {
return "", err
}
parts := strings.Split(filepath.ToSlash(key), "/")
current := cleanScope
for _, part := range parts {
if part == "" || part == "." || part == ".." {
return "", fmt.Errorf("workspace seed path contains unsafe component")
}
current = filepath.Join(current, part)
}
rel, err := filepath.Rel(cleanScope, current)
if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return "", fmt.Errorf("workspace seed path escapes scope")
}
return current, nil
}