Feat v0.3.8 distribution (#21)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #21.
This commit is contained in:
309
server/handlers/packages_bundled.go
Normal file
309
server/handlers/packages_bundled.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/database"
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/sandbox"
|
||||
"switchboard-core/store"
|
||||
"switchboard-core/triggers"
|
||||
)
|
||||
|
||||
// InstallBundledPackages scans bundledDir for .pkg archives and installs
|
||||
// any that don't already exist in the database. Called once at startup
|
||||
// (unless SKIP_BUNDLED_PACKAGES=true).
|
||||
//
|
||||
// allowlist is an optional comma-separated list of package IDs to install.
|
||||
// Empty string means install all. This allows Helm/K8s deployments to
|
||||
// select a subset: e.g. "tasks,schedules,hello-dashboard".
|
||||
//
|
||||
// Design: install-once, skip-if-present. If an admin uninstalls a bundled
|
||||
// package, it won't be re-installed on the next restart.
|
||||
func InstallBundledPackages(bundledDir, packagesDir, allowlist string, stores store.Stores, runner *sandbox.Runner) {
|
||||
entries, err := os.ReadDir(bundledDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("[bundled] No bundled packages directory at %s — skipping", bundledDir)
|
||||
return
|
||||
}
|
||||
log.Printf("[bundled] ⚠️ Cannot read bundled packages: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse allowlist into a set (empty = allow all)
|
||||
allowed := parseBundleAllowlist(allowlist)
|
||||
if len(allowed) > 0 {
|
||||
log.Printf("[bundled] Allowlist: %v", allowlist)
|
||||
}
|
||||
|
||||
var installed, skipped, filtered int
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".pkg") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check allowlist by filename (strip .pkg extension = package ID)
|
||||
if len(allowed) > 0 {
|
||||
pkgName := strings.TrimSuffix(entry.Name(), ".pkg")
|
||||
if !allowed[pkgName] {
|
||||
filtered++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
pkgPath := filepath.Join(bundledDir, entry.Name())
|
||||
result, err := installBundledPackage(pkgPath, packagesDir, stores, runner)
|
||||
if err != nil {
|
||||
log.Printf("[bundled] ⚠️ Failed to install %s: %v", entry.Name(), err)
|
||||
continue
|
||||
}
|
||||
if result == "skipped" {
|
||||
skipped++
|
||||
} else {
|
||||
installed++
|
||||
}
|
||||
}
|
||||
|
||||
if installed > 0 || skipped > 0 || filtered > 0 {
|
||||
msg := fmt.Sprintf("[bundled] Installed %d, skipped %d existing", installed, skipped)
|
||||
if filtered > 0 {
|
||||
msg += fmt.Sprintf(", filtered %d by allowlist", filtered)
|
||||
}
|
||||
log.Println(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// parseBundleAllowlist parses a comma-separated string into a set of
|
||||
// package IDs. Returns nil for empty input (meaning "allow all").
|
||||
func parseBundleAllowlist(s string) map[string]bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
m := make(map[string]bool, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
m[p] = true
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// installBundledPackage installs a single .pkg archive if its package ID
|
||||
// doesn't already exist in the database. Returns "installed" or "skipped".
|
||||
func installBundledPackage(pkgPath, packagesDir string, stores store.Stores, runner *sandbox.Runner) (string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Open as zip
|
||||
zr, err := zip.OpenReader(pkgPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid zip archive: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
// Find and parse manifest.json
|
||||
manifest, err := extractManifest(zr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
pkgID, _ := manifest["id"].(string)
|
||||
if pkgID == "" {
|
||||
return "", fmt.Errorf("manifest missing 'id'")
|
||||
}
|
||||
|
||||
// Skip if already exists (admin uninstalled → stays uninstalled)
|
||||
existing, _ := stores.Packages.Get(ctx, pkgID)
|
||||
if existing != nil {
|
||||
return "skipped", nil
|
||||
}
|
||||
|
||||
title, _ := manifest["title"].(string)
|
||||
if title == "" {
|
||||
return "", fmt.Errorf("manifest missing 'title'")
|
||||
}
|
||||
|
||||
pkgType, _ := manifest["type"].(string)
|
||||
if pkgType == "" {
|
||||
pkgType = "surface"
|
||||
}
|
||||
|
||||
// Extract static assets to packagesDir/{id}/
|
||||
if packagesDir != "" {
|
||||
if err := extractPackageAssets(zr, packagesDir, pkgID); err != nil {
|
||||
return "", fmt.Errorf("asset extraction: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract manifest fields for DB columns
|
||||
version, _ := manifest["version"].(string)
|
||||
if version == "" {
|
||||
version = "0.0.0"
|
||||
}
|
||||
description, _ := manifest["description"].(string)
|
||||
author, _ := manifest["author"].(string)
|
||||
tier, _ := manifest["tier"].(string)
|
||||
if tier == "" {
|
||||
tier = "browser"
|
||||
}
|
||||
|
||||
// Register in database via Seed (upsert)
|
||||
if err := stores.Packages.Seed(ctx, pkgID, title, "bundled", manifest); err != nil {
|
||||
return "", fmt.Errorf("seed failed: %w", err)
|
||||
}
|
||||
|
||||
// Update fields that Seed doesn't set
|
||||
pkg := &store.PackageRegistration{
|
||||
Title: title,
|
||||
Type: pkgType,
|
||||
Version: version,
|
||||
Description: description,
|
||||
Author: author,
|
||||
Tier: tier,
|
||||
IsSystem: false,
|
||||
Enabled: true,
|
||||
Manifest: manifest,
|
||||
}
|
||||
if err := stores.Packages.Update(ctx, pkgID, pkg); err != nil {
|
||||
log.Printf("[bundled] Failed to update package metadata for %s: %v", pkgID, err)
|
||||
}
|
||||
|
||||
// Create namespaced DB tables declared in the manifest
|
||||
if tables, ok := ParseDBTables(manifest); ok {
|
||||
if err := CreateExtTables(ctx, database.DB, database.IsPostgres(), stores, pkgID, tables); err != nil {
|
||||
log.Printf("[bundled] [ext-db] schema create failed for %s: %v", pkgID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync permissions — use a minimal gin.Context for the functions that need it
|
||||
fakeCtx := newBackgroundGinContext()
|
||||
SyncManifestPermissions(fakeCtx, stores, pkgID, manifest)
|
||||
|
||||
// Sync triggers from manifest
|
||||
SyncManifestTriggers(ctx, stores, triggers.GlobalEngine(), pkgID, manifest)
|
||||
|
||||
// Install workflow definition if applicable
|
||||
if pkgType == "workflow" {
|
||||
if err := InstallWorkflowFromManifest(fakeCtx, stores, pkgID, manifest); err != nil {
|
||||
log.Printf("[bundled] workflow install failed for %s: %v", pkgID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dormant status for packages with unmet requires
|
||||
knownCaps := map[string]bool{}
|
||||
if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 {
|
||||
var unmetReqs []string
|
||||
for _, r := range reqs {
|
||||
req, _ := r.(string)
|
||||
if req != "" && !knownCaps[req] {
|
||||
unmetReqs = append(unmetReqs, req)
|
||||
}
|
||||
}
|
||||
if len(unmetReqs) > 0 {
|
||||
stores.Packages.SetStatus(ctx, pkgID, "dormant")
|
||||
stores.Packages.SetEnabled(ctx, pkgID, false)
|
||||
log.Printf("[bundled] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-set default_surface when the first surface is installed
|
||||
if (pkgType == "surface" || pkgType == "full") {
|
||||
if raw, err := stores.GlobalConfig.Get(ctx, "default_surface"); err != nil || raw == nil {
|
||||
dflt := models.JSONMap{"id": pkgID}
|
||||
if setErr := stores.GlobalConfig.Set(ctx, "default_surface", dflt, ""); setErr != nil {
|
||||
log.Printf("[bundled] failed to auto-set default_surface to %s: %v", pkgID, setErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[bundled] Installed %s (%s) v%s", pkgID, pkgType, version)
|
||||
return "installed", nil
|
||||
}
|
||||
|
||||
// extractManifest reads and parses manifest.json from a zip archive.
|
||||
func extractManifest(zr *zip.ReadCloser) (map[string]any, error) {
|
||||
for _, f := range zr.File {
|
||||
name := filepath.Base(f.Name)
|
||||
if name == "manifest.json" && !f.FileInfo().IsDir() {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var manifest map[string]any
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("invalid manifest.json: %w", err)
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("archive missing manifest.json")
|
||||
}
|
||||
|
||||
// extractPackageAssets extracts static assets from a zip archive to packagesDir/{pkgID}/.
|
||||
func extractPackageAssets(zr *zip.ReadCloser, packagesDir, pkgID string) error {
|
||||
destDir := filepath.Join(packagesDir, pkgID)
|
||||
os.MkdirAll(destDir, 0755)
|
||||
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
relPath := extractableRelPath(f.Name)
|
||||
if relPath == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
destPath := filepath.Join(destDir, relPath)
|
||||
|
||||
// Security: prevent path traversal
|
||||
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
|
||||
continue
|
||||
}
|
||||
|
||||
os.MkdirAll(filepath.Dir(destPath), 0755)
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
continue
|
||||
}
|
||||
io.Copy(out, rc)
|
||||
out.Close()
|
||||
rc.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newBackgroundGinContext creates a minimal gin.Context backed by a
|
||||
// background context. Used by startup code that calls functions
|
||||
// originally designed for HTTP handlers.
|
||||
func newBackgroundGinContext() *gin.Context {
|
||||
req, _ := http.NewRequestWithContext(context.Background(), "GET", "/", nil)
|
||||
c := &gin.Context{}
|
||||
c.Request = req
|
||||
return c
|
||||
}
|
||||
Reference in New Issue
Block a user