2294 lines
74 KiB
Go
2294 lines
74 KiB
Go
package apps
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/wild-cloud/wild-central/daemon/internal/operations"
|
|
"github.com/wild-cloud/wild-central/daemon/internal/secrets"
|
|
"github.com/wild-cloud/wild-central/daemon/internal/storage"
|
|
"github.com/wild-cloud/wild-central/daemon/internal/tools"
|
|
"github.com/wild-cloud/wild-central/daemon/internal/utilities"
|
|
)
|
|
|
|
// Manager handles application lifecycle operations
|
|
type Manager struct {
|
|
dataDir string
|
|
appsDir string // Path to apps directory in repo
|
|
}
|
|
|
|
// NewManager creates a new apps manager
|
|
func NewManager(dataDir, appsDir string) *Manager {
|
|
return &Manager{
|
|
dataDir: dataDir,
|
|
appsDir: appsDir,
|
|
}
|
|
}
|
|
|
|
// ResolveNamespace determines the Kubernetes namespace for an app.
|
|
// Priority: config.yaml (apps.<appName>.namespace) > manifest namespace field > appName fallback.
|
|
func (m *Manager) ResolveNamespace(instanceName, appName string) string {
|
|
// 1. Check config.yaml: apps.<appName>.namespace
|
|
configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
|
yq := tools.NewYQ()
|
|
if ns, err := yq.Get(configFile, fmt.Sprintf(".apps.%s.namespace", appName)); err == nil && ns != "" && ns != "null" {
|
|
return strings.TrimSpace(ns)
|
|
}
|
|
|
|
// 2. Check manifest namespace field (legacy)
|
|
instancePath := tools.GetInstancePath(m.dataDir, instanceName)
|
|
manifestPath := filepath.Join(instancePath, "apps", appName, "manifest.yaml")
|
|
if storage.FileExists(manifestPath) {
|
|
data, _ := os.ReadFile(manifestPath)
|
|
var manifest struct {
|
|
Namespace string `yaml:"namespace"`
|
|
}
|
|
if yaml.Unmarshal(data, &manifest) == nil && manifest.Namespace != "" {
|
|
return manifest.Namespace
|
|
}
|
|
}
|
|
|
|
// 3. Fallback: appName
|
|
return appName
|
|
}
|
|
|
|
// App represents an application
|
|
type App struct {
|
|
Name string `json:"name" yaml:"name"`
|
|
Description string `json:"description" yaml:"description"`
|
|
Version string `json:"version" yaml:"version"`
|
|
Category string `json:"category,omitempty" yaml:"category,omitempty"`
|
|
Icon string `json:"icon,omitempty" yaml:"icon,omitempty"`
|
|
Requires []AppDependency `json:"requires,omitempty" yaml:"requires,omitempty"`
|
|
Dependencies []string `json:"dependencies" yaml:"dependencies"` // Deprecated: kept for backward compatibility
|
|
Config map[string]string `json:"config,omitempty" yaml:"config,omitempty"`
|
|
DefaultConfig map[string]interface{} `json:"defaultConfig,omitempty" yaml:"defaultConfig,omitempty"`
|
|
DefaultSecrets []string `json:"defaultSecrets,omitempty" yaml:"defaultSecrets,omitempty"`
|
|
}
|
|
|
|
// DeployedApp represents a deployed application instance
|
|
type DeployedApp struct {
|
|
Name string `json:"name"`
|
|
Is string `json:"is,omitempty"` // The original app type
|
|
Status string `json:"status"`
|
|
Version string `json:"version"`
|
|
Category string `json:"category,omitempty"`
|
|
Namespace string `json:"namespace"`
|
|
URL string `json:"url,omitempty"`
|
|
Icon string `json:"icon,omitempty"`
|
|
}
|
|
|
|
// ListAvailable lists all available apps from the apps directory
|
|
// If category is non-empty, only apps matching that category are returned.
|
|
func (m *Manager) ListAvailable(category string) ([]App, error) {
|
|
if m.appsDir == "" {
|
|
return []App{}, fmt.Errorf("apps directory not configured")
|
|
}
|
|
|
|
// Read apps directory
|
|
entries, err := os.ReadDir(m.appsDir)
|
|
if err != nil {
|
|
return []App{}, fmt.Errorf("failed to read apps directory: %w", err)
|
|
}
|
|
|
|
apps := []App{}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
// Check for manifest.yaml
|
|
appFile := filepath.Join(m.appsDir, entry.Name(), "manifest.yaml")
|
|
if !storage.FileExists(appFile) {
|
|
continue
|
|
}
|
|
|
|
// Parse manifest.yaml
|
|
data, err := os.ReadFile(appFile)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var manifest AppManifest
|
|
if err := yaml.Unmarshal(data, &manifest); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Convert manifest to App struct
|
|
app := App{
|
|
Name: entry.Name(), // Use directory name as app name
|
|
Description: manifest.Description,
|
|
Version: manifest.Version,
|
|
Category: manifest.Category,
|
|
Icon: manifest.Icon,
|
|
DefaultConfig: manifest.DefaultConfig,
|
|
Requires: manifest.Requires, // Include full dependency information
|
|
}
|
|
|
|
// Extract secret keys from DefaultSecrets
|
|
if len(manifest.DefaultSecrets) > 0 {
|
|
app.DefaultSecrets = make([]string, len(manifest.DefaultSecrets))
|
|
for i, secret := range manifest.DefaultSecrets {
|
|
app.DefaultSecrets[i] = secret.Key
|
|
}
|
|
}
|
|
|
|
// Extract dependencies from Requires field (for backward compatibility)
|
|
if len(manifest.Requires) > 0 {
|
|
app.Dependencies = make([]string, len(manifest.Requires))
|
|
for i, dep := range manifest.Requires {
|
|
app.Dependencies[i] = dep.Name
|
|
}
|
|
}
|
|
|
|
// Filter by category if specified
|
|
if category != "" && manifest.Category != category {
|
|
continue
|
|
}
|
|
|
|
apps = append(apps, app)
|
|
}
|
|
|
|
return apps, nil
|
|
}
|
|
|
|
// Get returns details for a specific available app
|
|
func (m *Manager) Get(appName string) (*App, error) {
|
|
appFile := filepath.Join(m.appsDir, appName, "manifest.yaml")
|
|
|
|
if !storage.FileExists(appFile) {
|
|
return nil, fmt.Errorf("app %s not found", appName)
|
|
}
|
|
|
|
data, err := os.ReadFile(appFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read app file: %w", err)
|
|
}
|
|
|
|
var manifest AppManifest
|
|
if err := yaml.Unmarshal(data, &manifest); err != nil {
|
|
return nil, fmt.Errorf("failed to parse app file: %w", err)
|
|
}
|
|
|
|
// Convert manifest to App struct
|
|
app := &App{
|
|
Name: appName,
|
|
Description: manifest.Description,
|
|
Version: manifest.Version,
|
|
Category: manifest.Category,
|
|
Icon: manifest.Icon,
|
|
DefaultConfig: manifest.DefaultConfig,
|
|
Requires: manifest.Requires, // Include full dependency information
|
|
}
|
|
|
|
// Extract secret keys from DefaultSecrets
|
|
if len(manifest.DefaultSecrets) > 0 {
|
|
app.DefaultSecrets = make([]string, len(manifest.DefaultSecrets))
|
|
for i, secret := range manifest.DefaultSecrets {
|
|
app.DefaultSecrets[i] = secret.Key
|
|
}
|
|
}
|
|
|
|
// Extract dependencies from Requires field (for backward compatibility)
|
|
if len(manifest.Requires) > 0 {
|
|
app.Dependencies = make([]string, len(manifest.Requires))
|
|
for i, dep := range manifest.Requires {
|
|
app.Dependencies[i] = dep.Name
|
|
}
|
|
}
|
|
|
|
return app, nil
|
|
}
|
|
|
|
// ListDeployed lists deployed apps for an instance
|
|
// If category is provided and non-empty, only apps matching that category are returned.
|
|
func (m *Manager) ListDeployed(instanceName string, category ...string) ([]DeployedApp, error) {
|
|
kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName)
|
|
instancePath := tools.GetInstancePath(m.dataDir, instanceName)
|
|
appsDir := filepath.Join(instancePath, "apps")
|
|
|
|
apps := []DeployedApp{}
|
|
|
|
// Check if apps directory exists
|
|
if !storage.FileExists(appsDir) {
|
|
return apps, nil
|
|
}
|
|
|
|
// List all app directories
|
|
entries, err := os.ReadDir(appsDir)
|
|
if err != nil {
|
|
return apps, fmt.Errorf("failed to read apps directory: %w", err)
|
|
}
|
|
|
|
// For each app directory, check if it's deployed in the cluster
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
appName := entry.Name()
|
|
|
|
// Initialize app with basic info
|
|
app := DeployedApp{
|
|
Name: appName,
|
|
Namespace: m.ResolveNamespace(instanceName, appName),
|
|
Status: "added", // Default status: added but not deployed
|
|
}
|
|
|
|
// Try to get version, 'is', icon, and category from manifest
|
|
manifestPath := filepath.Join(appsDir, appName, "manifest.yaml")
|
|
if storage.FileExists(manifestPath) {
|
|
manifestData, _ := os.ReadFile(manifestPath)
|
|
var manifest struct {
|
|
Version string `yaml:"version"`
|
|
Is string `yaml:"is"`
|
|
Icon string `yaml:"icon"`
|
|
Category string `yaml:"category"`
|
|
}
|
|
if yaml.Unmarshal(manifestData, &manifest) == nil {
|
|
app.Version = manifest.Version
|
|
app.Is = manifest.Is
|
|
app.Icon = manifest.Icon
|
|
app.Category = manifest.Category
|
|
|
|
// Filter by category if specified
|
|
if len(category) > 0 && category[0] != "" && manifest.Category != category[0] {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if namespace exists in cluster
|
|
checkCmd := exec.Command("kubectl", "get", "namespace", app.Namespace, "-o", "json")
|
|
tools.WithKubeconfig(checkCmd, kubeconfigPath)
|
|
output, err := checkCmd.CombinedOutput()
|
|
|
|
if err == nil {
|
|
// Namespace exists - parse status
|
|
var ns struct {
|
|
Status struct {
|
|
Phase string `json:"phase"`
|
|
} `json:"status"`
|
|
}
|
|
if yaml.Unmarshal(output, &ns) == nil && ns.Status.Phase == "Active" {
|
|
// Namespace is active - app is deployed
|
|
app.Status = "deployed"
|
|
|
|
// Get ingress URL if available
|
|
// Try Traefik IngressRoute first
|
|
ingressCmd := exec.Command("kubectl", "get", "ingressroute", "-n", app.Namespace, "-o", "json")
|
|
tools.WithKubeconfig(ingressCmd, kubeconfigPath)
|
|
ingressOutput, err := ingressCmd.CombinedOutput()
|
|
|
|
if err == nil {
|
|
var ingressList struct {
|
|
Items []struct {
|
|
Spec struct {
|
|
Routes []struct {
|
|
Match string `json:"match"`
|
|
} `json:"routes"`
|
|
} `json:"spec"`
|
|
} `json:"items"`
|
|
}
|
|
if json.Unmarshal(ingressOutput, &ingressList) == nil && len(ingressList.Items) > 0 {
|
|
// Extract host from the first route match (format: Host(`example.com`))
|
|
if len(ingressList.Items[0].Spec.Routes) > 0 {
|
|
match := ingressList.Items[0].Spec.Routes[0].Match
|
|
// Parse Host(`domain.com`) format
|
|
if strings.Contains(match, "Host(`") {
|
|
start := strings.Index(match, "Host(`") + 6
|
|
end := strings.Index(match[start:], "`")
|
|
if end > 0 {
|
|
host := match[start : start+end]
|
|
app.URL = "https://" + host
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no IngressRoute, try standard Ingress
|
|
if app.URL == "" {
|
|
ingressCmd := exec.Command("kubectl", "get", "ingress", "-n", app.Namespace, "-o", "json")
|
|
tools.WithKubeconfig(ingressCmd, kubeconfigPath)
|
|
ingressOutput, err := ingressCmd.CombinedOutput()
|
|
|
|
if err == nil {
|
|
var ingressList struct {
|
|
Items []struct {
|
|
Spec struct {
|
|
Rules []struct {
|
|
Host string `json:"host"`
|
|
} `json:"rules"`
|
|
} `json:"spec"`
|
|
} `json:"items"`
|
|
}
|
|
if json.Unmarshal(ingressOutput, &ingressList) == nil && len(ingressList.Items) > 0 {
|
|
if len(ingressList.Items[0].Spec.Rules) > 0 {
|
|
host := ingressList.Items[0].Spec.Rules[0].Host
|
|
if host != "" {
|
|
app.URL = "https://" + host
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
apps = append(apps, app)
|
|
}
|
|
|
|
return apps, nil
|
|
}
|
|
|
|
|
|
// processSecretTemplate processes a gomplate template for secret defaults
|
|
// This function uses named contexts for config and secrets (e.g., {{ .config.apps.loomio.db.user }}, {{ .secrets.apps.loomio.dbPassword }})
|
|
func processSecretTemplate(template string, appName string, configFile, secretsFile string, gomplate *tools.Gomplate) (string, error) {
|
|
// Create merged context file with app-specific config under "app" key
|
|
mergedContextFile := filepath.Join(filepath.Dir(configFile), fmt.Sprintf(".merged-secrets.%s.tmp.yaml", appName))
|
|
defer os.Remove(mergedContextFile)
|
|
|
|
// Load root config
|
|
rootData, err := os.ReadFile(configFile)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read config file: %w", err)
|
|
}
|
|
|
|
var rootConfig map[string]interface{}
|
|
if err := yaml.Unmarshal(rootData, &rootConfig); err != nil {
|
|
return "", fmt.Errorf("failed to parse config: %w", err)
|
|
}
|
|
|
|
// Extract app-specific config and add it under "app" key
|
|
if apps, ok := rootConfig["apps"].(map[string]interface{}); ok {
|
|
if appConfig, ok := apps[appName].(map[string]interface{}); ok {
|
|
rootConfig["app"] = appConfig
|
|
}
|
|
}
|
|
|
|
// Load secrets and extract app-specific secrets under "secrets" key
|
|
secretsData, err := os.ReadFile(secretsFile)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read secrets file: %w", err)
|
|
}
|
|
|
|
var allSecrets map[string]interface{}
|
|
if err := yaml.Unmarshal(secretsData, &allSecrets); err != nil {
|
|
return "", fmt.Errorf("failed to parse secrets: %w", err)
|
|
}
|
|
|
|
// Extract app-specific secrets and add them under "secrets" key
|
|
if apps, ok := allSecrets["apps"].(map[string]interface{}); ok {
|
|
if appSecrets, ok := apps[appName].(map[string]interface{}); ok {
|
|
rootConfig["secrets"] = appSecrets
|
|
}
|
|
}
|
|
|
|
// Write merged config
|
|
mergedYAML, err := yaml.Marshal(rootConfig)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal merged config: %w", err)
|
|
}
|
|
if err := storage.WriteFile(mergedContextFile, mergedYAML, 0644); err != nil {
|
|
return "", fmt.Errorf("failed to write merged config: %w", err)
|
|
}
|
|
|
|
args := []string{
|
|
"-i", template,
|
|
"-c", fmt.Sprintf(".=%s", mergedContextFile),
|
|
}
|
|
|
|
compiled, err := gomplate.Exec(args...)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to process template: %w", err)
|
|
}
|
|
|
|
return strings.TrimSpace(compiled), nil
|
|
}
|
|
|
|
// ensureDefaultSecrets generates any missing secrets defined in a manifest.
|
|
// Existing secrets are preserved. New secrets are either generated randomly
|
|
// or compiled from their default template.
|
|
func ensureDefaultSecrets(secretDefs []SecretDefinition, appName, configFile, secretsFile string) error {
|
|
secretsMgr := secrets.NewManager()
|
|
gomplate := tools.NewGomplate()
|
|
|
|
for _, secretDef := range secretDefs {
|
|
secretPath := fmt.Sprintf("apps.%s.%s", appName, secretDef.Key)
|
|
|
|
existingSecret, _ := secretsMgr.GetSecret(secretsFile, secretPath)
|
|
if existingSecret != "" && existingSecret != "null" {
|
|
continue
|
|
}
|
|
|
|
var secretValue string
|
|
if secretDef.Default != "" {
|
|
if strings.Contains(secretDef.Default, "{{") {
|
|
compiled, err := processSecretTemplate(secretDef.Default, appName, configFile, secretsFile, gomplate)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to compile secret template for %s: %w", secretDef.Key, err)
|
|
}
|
|
secretValue = compiled
|
|
} else {
|
|
secretValue = secretDef.Default
|
|
}
|
|
} else {
|
|
var err error
|
|
secretValue, err = secrets.GenerateSecret(secrets.DefaultSecretLength)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate secret for %s: %w", secretDef.Key, err)
|
|
}
|
|
}
|
|
|
|
if err := secretsMgr.SetSecret(secretsFile, secretPath, secretValue); err != nil {
|
|
return fmt.Errorf("failed to set secret %s: %w", secretPath, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// setNestedConfig recursively sets nested configuration values using yq
|
|
func setNestedConfig(yq *tools.YQ, configFile, basePath string, value interface{}) error {
|
|
switch v := value.(type) {
|
|
case map[string]interface{}:
|
|
// Handle nested map - recursively set each field
|
|
for k, nested := range v {
|
|
nestedPath := fmt.Sprintf("%s.%s", basePath, k)
|
|
if err := setNestedConfig(yq, configFile, nestedPath, nested); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case map[interface{}]interface{}:
|
|
// YAML unmarshaling sometimes produces this type
|
|
for k, nested := range v {
|
|
key := fmt.Sprintf("%v", k)
|
|
nestedPath := fmt.Sprintf("%s.%s", basePath, key)
|
|
if err := setNestedConfig(yq, configFile, nestedPath, nested); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
default:
|
|
// Primitive value - set it directly
|
|
return yq.Set(configFile, basePath, fmt.Sprintf("%v", value))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Add adds an app to the instance configuration
|
|
func (m *Manager) Add(instanceName, appName string, config map[string]interface{}, requiredAppMappings map[string]string) error {
|
|
// 1. Verify app exists
|
|
manifestPath := filepath.Join(m.appsDir, appName, "manifest.yaml")
|
|
if !storage.FileExists(manifestPath) {
|
|
return fmt.Errorf("app %s not found at %s", appName, manifestPath)
|
|
}
|
|
|
|
instancePath := tools.GetInstancePath(m.dataDir, instanceName)
|
|
configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
|
secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName)
|
|
appDestDir := filepath.Join(instancePath, "apps", appName)
|
|
|
|
// Check instance config exists
|
|
if !storage.FileExists(configFile) {
|
|
return fmt.Errorf("instance config not found: %s", instanceName)
|
|
}
|
|
|
|
// Create app directory structure
|
|
if err := storage.EnsureDir(appDestDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create app directory: %w", err)
|
|
}
|
|
|
|
// Create .package directory for source preservation
|
|
packageDir := filepath.Join(appDestDir, ".package")
|
|
if err := storage.EnsureDir(packageDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create package directory: %w", err)
|
|
}
|
|
|
|
// 2. Parse manifest
|
|
manifestData, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read manifest: %w", err)
|
|
}
|
|
|
|
var manifest AppManifest
|
|
if err := yaml.Unmarshal(manifestData, &manifest); err != nil {
|
|
return fmt.Errorf("failed to parse manifest: %w", err)
|
|
}
|
|
|
|
// 3. Update configuration
|
|
yq := tools.NewYQ()
|
|
configLock := configFile + ".lock"
|
|
|
|
if err := storage.WithLock(configLock, func() error {
|
|
// Ensure apps section exists
|
|
expr := fmt.Sprintf(".apps.%s = .apps.%s // {}", appName, appName)
|
|
if _, err := yq.Exec("-i", expr, configFile); err != nil {
|
|
return fmt.Errorf("failed to ensure apps section: %w", err)
|
|
}
|
|
|
|
// Process config in order from manifest YAML to handle {{ .app.X }} references correctly
|
|
// Use the source manifest since the destination hasn't been copied yet
|
|
sourceManifestPath := filepath.Join(m.appsDir, appName, "manifest.yaml")
|
|
if err := processConfigInOrder(sourceManifestPath, appName, configFile); err != nil {
|
|
return fmt.Errorf("failed to process config in order: %w", err)
|
|
}
|
|
|
|
// Apply user-provided config overrides (process templates first)
|
|
if len(config) > 0 {
|
|
gomplate := tools.NewGomplate()
|
|
processedConfig, err := processUserConfig(config, appName, configFile, gomplate)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to process user config: %w", err)
|
|
}
|
|
|
|
for key, value := range processedConfig {
|
|
keyPath := fmt.Sprintf(".apps.%s.%s", appName, key)
|
|
// Use setNestedConfig to handle both simple values and nested objects
|
|
if err := setNestedConfig(yq, configFile, keyPath, value); err != nil {
|
|
return fmt.Errorf("failed to set config %s: %w", key, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 4. Generate required secrets
|
|
if err := ensureDefaultSecrets(manifest.DefaultSecrets, appName, configFile, secretsFile); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 5. Copy source files to .package directory first
|
|
sourceAppDir := filepath.Join(m.appsDir, appName)
|
|
entries, err := os.ReadDir(sourceAppDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read app directory: %w", err)
|
|
}
|
|
|
|
// Build set of source file names for cleanup
|
|
sourceNames := make(map[string]bool, len(entries))
|
|
for _, entry := range entries {
|
|
sourceNames[entry.Name()] = true
|
|
}
|
|
|
|
// Remove stale files from .package and app directory that no longer exist in source
|
|
if existingEntries, err := os.ReadDir(packageDir); err == nil {
|
|
for _, entry := range existingEntries {
|
|
if !sourceNames[entry.Name()] {
|
|
os.RemoveAll(filepath.Join(packageDir, entry.Name()))
|
|
os.RemoveAll(filepath.Join(appDestDir, entry.Name()))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Copy all source files to .package directory
|
|
for _, entry := range entries {
|
|
sourcePath := filepath.Join(sourceAppDir, entry.Name())
|
|
packagePath := filepath.Join(packageDir, entry.Name())
|
|
|
|
if entry.IsDir() {
|
|
subSrcDir := filepath.Join(sourceAppDir, entry.Name())
|
|
subDstDir := filepath.Join(packageDir, entry.Name())
|
|
if err := copyDir(subSrcDir, subDstDir); err != nil {
|
|
return fmt.Errorf("failed to copy directory %s: %w", entry.Name(), err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Copy file to .package
|
|
data, err := os.ReadFile(sourcePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read %s: %w", entry.Name(), err)
|
|
}
|
|
if err := storage.WriteFile(packagePath, data, 0644); err != nil {
|
|
return fmt.Errorf("failed to write to package %s: %w", entry.Name(), err)
|
|
}
|
|
}
|
|
|
|
// Update manifest with source information and required app mappings
|
|
manifestDestPath := filepath.Join(appDestDir, "manifest.yaml")
|
|
if manifest.Source == "" {
|
|
// Construct source URI based on appsDir location
|
|
sourceAppDir := filepath.Join(m.appsDir, appName)
|
|
absPath, err := filepath.Abs(sourceAppDir)
|
|
if err != nil {
|
|
absPath = sourceAppDir
|
|
}
|
|
manifest.Source = fmt.Sprintf("file://%s", absPath)
|
|
}
|
|
|
|
// Note: The 'is' field should already be set in the manifest from the directory
|
|
// It tracks the original app type and is required in all manifests
|
|
|
|
// Update Requires field with installedAs values from requiredAppMappings
|
|
if len(requiredAppMappings) > 0 && len(manifest.Requires) > 0 {
|
|
for i := range manifest.Requires {
|
|
// Use alias as key, or name if no alias
|
|
key := manifest.Requires[i].Name
|
|
if manifest.Requires[i].Alias != "" {
|
|
key = manifest.Requires[i].Alias
|
|
}
|
|
|
|
// Set installedAs from the mapping
|
|
if installedAs, ok := requiredAppMappings[key]; ok {
|
|
manifest.Requires[i].InstalledAs = installedAs
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save updated manifest
|
|
manifestYAML, err := yaml.Marshal(manifest)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal manifest: %w", err)
|
|
}
|
|
if err := storage.WriteFile(manifestDestPath, manifestYAML, 0644); err != nil {
|
|
return fmt.Errorf("failed to write manifest: %w", err)
|
|
}
|
|
|
|
// 6. Compile app files from .package to app directory
|
|
if err := m.compileFromPackage(appName, appDestDir, packageDir, configFile, secretsFile); err != nil {
|
|
return fmt.Errorf("failed to compile app templates: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Deploy deploys an app to the cluster
|
|
func (m *Manager) Deploy(instanceName, appName string, opID string, broadcaster *operations.Broadcaster) error {
|
|
kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName)
|
|
instancePath := tools.GetInstancePath(m.dataDir, instanceName)
|
|
secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName)
|
|
|
|
// Get compiled app manifests from instance directory
|
|
appDir := filepath.Join(instancePath, "apps", appName)
|
|
if !storage.FileExists(appDir) {
|
|
return fmt.Errorf("app %s not found in instance (run 'wild app add %s' first)", appName, appName)
|
|
}
|
|
|
|
// Check if app has an install.sh script (infrastructure packages)
|
|
installScript := filepath.Join(appDir, "install.sh")
|
|
if _, err := os.Stat(installScript); err == nil {
|
|
return m.deployWithScript(instanceName, appName, appDir, installScript, kubeconfigPath, opID, broadcaster)
|
|
}
|
|
|
|
// Load app manifest
|
|
manifestPath := filepath.Join(appDir, "manifest.yaml")
|
|
var manifest AppManifest
|
|
if storage.FileExists(manifestPath) {
|
|
manifestData, err := os.ReadFile(manifestPath)
|
|
if err == nil {
|
|
yaml.Unmarshal(manifestData, &manifest)
|
|
}
|
|
}
|
|
|
|
// Check deploy config prerequisites
|
|
if manifest.Deploy != nil && manifest.Deploy.RequireWorkerNodes {
|
|
cmd := exec.Command("kubectl", "get", "nodes", "--selector=!node-role.kubernetes.io/control-plane", "-o", "name")
|
|
tools.WithKubeconfig(cmd, kubeconfigPath)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil || strings.TrimSpace(string(output)) == "" {
|
|
return fmt.Errorf("no worker nodes found in cluster; %s requires worker nodes", appName)
|
|
}
|
|
}
|
|
|
|
// Apply CRDs before namespace and resources
|
|
if manifest.Deploy != nil {
|
|
for _, crd := range manifest.Deploy.CRDs {
|
|
cmd := exec.Command("kubectl", "apply", "-f", crd.URL)
|
|
tools.WithKubeconfig(cmd, kubeconfigPath)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("failed to apply CRDs from %s: %w\nOutput: %s", crd.URL, err, string(output))
|
|
}
|
|
for _, crdName := range crd.WaitFor {
|
|
waitCmd := exec.Command("kubectl", "wait", "--for=condition=established", fmt.Sprintf("crd/%s", crdName), "--timeout=60s")
|
|
tools.WithKubeconfig(waitCmd, kubeconfigPath)
|
|
if output, err := waitCmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("CRD %s not established: %w\nOutput: %s", crdName, err, string(output))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
namespace := m.ResolveNamespace(instanceName, appName)
|
|
|
|
// Create namespace if it doesn't exist
|
|
namespaceCmd := exec.Command("kubectl", "create", "namespace", namespace, "--dry-run=client", "-o", "yaml")
|
|
tools.WithKubeconfig(namespaceCmd, kubeconfigPath)
|
|
namespaceYaml, _ := namespaceCmd.CombinedOutput()
|
|
|
|
applyNsCmd := exec.Command("kubectl", "apply", "-f", "-")
|
|
applyNsCmd.Stdin = bytes.NewReader(namespaceYaml)
|
|
tools.WithKubeconfig(applyNsCmd, kubeconfigPath)
|
|
_, _ = applyNsCmd.CombinedOutput()
|
|
|
|
// Check if this app needs postgres-secrets (for db-init jobs)
|
|
dbInitJobPath := filepath.Join(appDir, "db-init-job.yaml")
|
|
if storage.FileExists(dbInitJobPath) {
|
|
dbInitContent, _ := os.ReadFile(dbInitJobPath)
|
|
if bytes.Contains(dbInitContent, []byte("postgres-secrets")) {
|
|
getSecretCmd := exec.Command("kubectl", "get", "secret", "postgres-secrets",
|
|
"-n", "postgres", "-o", "yaml")
|
|
tools.WithKubeconfig(getSecretCmd, kubeconfigPath)
|
|
secretYaml, err := getSecretCmd.CombinedOutput()
|
|
if err == nil {
|
|
secretYaml = bytes.ReplaceAll(secretYaml, []byte("namespace: postgres"),
|
|
[]byte(fmt.Sprintf("namespace: %s", namespace)))
|
|
applySecretCmd := exec.Command("kubectl", "apply", "-f", "-")
|
|
applySecretCmd.Stdin = bytes.NewReader(secretYaml)
|
|
tools.WithKubeconfig(applySecretCmd, kubeconfigPath)
|
|
_, _ = applySecretCmd.CombinedOutput()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Copy wildcard TLS certificates from cert-manager namespace
|
|
ingressPath := filepath.Join(appDir, "ingress.yaml")
|
|
if storage.FileExists(ingressPath) {
|
|
ingressContent, _ := os.ReadFile(ingressPath)
|
|
wildcardSecrets := []string{"wildcard-wild-cloud-tls", "wildcard-internal-wild-cloud-tls"}
|
|
for _, secretName := range wildcardSecrets {
|
|
if bytes.Contains(ingressContent, []byte(secretName)) {
|
|
if err := utilities.CopySecretBetweenNamespaces(kubeconfigPath, secretName, "cert-manager", namespace); err != nil {
|
|
fmt.Printf("Warning: Failed to copy TLS secret %s: %v\n", secretName, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create Kubernetes secrets from secrets.yaml
|
|
if storage.FileExists(secretsFile) {
|
|
secretsMgr := secrets.NewManager()
|
|
|
|
// Create app-secrets (from defaultSecrets + requiredSecrets)
|
|
deleteCmd := exec.Command("kubectl", "delete", "secret", fmt.Sprintf("%s-secrets", appName), "-n", namespace, "--ignore-not-found")
|
|
tools.WithKubeconfig(deleteCmd, kubeconfigPath)
|
|
_, _ = deleteCmd.CombinedOutput()
|
|
|
|
createSecretCmd := exec.Command("kubectl", "create", "secret", "generic", fmt.Sprintf("%s-secrets", appName), "-n", namespace)
|
|
|
|
if len(manifest.DefaultSecrets) > 0 {
|
|
for _, secretDef := range manifest.DefaultSecrets {
|
|
secretPath := fmt.Sprintf("apps.%s.%s", appName, secretDef.Key)
|
|
secretValue, err := secretsMgr.GetSecret(secretsFile, secretPath)
|
|
if err == nil && secretValue != "" && secretValue != "null" {
|
|
createSecretCmd.Args = append(createSecretCmd.Args, fmt.Sprintf("--from-literal=%s=%s", secretDef.Key, secretValue))
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(manifest.RequiredSecrets) > 0 {
|
|
for _, requiredSecret := range manifest.RequiredSecrets {
|
|
secretPath := fmt.Sprintf("apps.%s", requiredSecret)
|
|
secretValue, err := secretsMgr.GetSecret(secretsFile, secretPath)
|
|
if err != nil || secretValue == "" || secretValue == "null" {
|
|
secretValue, _ = secretsMgr.GetSecret(secretsFile, requiredSecret)
|
|
}
|
|
if secretValue != "" && secretValue != "null" {
|
|
createSecretCmd.Args = append(createSecretCmd.Args,
|
|
fmt.Sprintf("--from-literal=%s=%s", requiredSecret, secretValue))
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(createSecretCmd.Args) > 5 { // base command has 5 args
|
|
tools.WithKubeconfig(createSecretCmd, kubeconfigPath)
|
|
if output, err := createSecretCmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("failed to create secret: %w\nOutput: %s", err, string(output))
|
|
}
|
|
}
|
|
|
|
// Create additional secrets from deploy config
|
|
if manifest.Deploy != nil {
|
|
for _, cs := range manifest.Deploy.CreateSecrets {
|
|
targetNs := namespace
|
|
if cs.Namespace != "" {
|
|
targetNs = cs.Namespace
|
|
}
|
|
|
|
delCmd := exec.Command("kubectl", "delete", "secret", cs.Name, "-n", targetNs, "--ignore-not-found")
|
|
tools.WithKubeconfig(delCmd, kubeconfigPath)
|
|
_, _ = delCmd.CombinedOutput()
|
|
|
|
createCmd := exec.Command("kubectl", "create", "secret", "generic", cs.Name, "-n", targetNs)
|
|
for k8sKey, secretRef := range cs.Entries {
|
|
// Look up from app's secrets first, then from source app
|
|
secretPath := fmt.Sprintf("apps.%s.%s", appName, secretRef)
|
|
secretValue, err := secretsMgr.GetSecret(secretsFile, secretPath)
|
|
if err != nil || secretValue == "" || secretValue == "null" {
|
|
secretPath = fmt.Sprintf("apps.%s", secretRef)
|
|
secretValue, _ = secretsMgr.GetSecret(secretsFile, secretPath)
|
|
}
|
|
if secretValue != "" && secretValue != "null" {
|
|
createCmd.Args = append(createCmd.Args, fmt.Sprintf("--from-literal=%s=%s", k8sKey, secretValue))
|
|
}
|
|
}
|
|
if len(createCmd.Args) > 5 {
|
|
tools.WithKubeconfig(createCmd, kubeconfigPath)
|
|
if output, err := createCmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("failed to create secret %s: %w\nOutput: %s", cs.Name, err, string(output))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply manifests
|
|
if manifest.Deploy != nil && len(manifest.Deploy.Phases) > 0 {
|
|
// Multi-phase deployment
|
|
for _, phase := range manifest.Deploy.Phases {
|
|
phaseDir := filepath.Join(appDir, phase.Path)
|
|
cmd := exec.Command("kubectl", "apply", "-k", phaseDir)
|
|
tools.WithKubeconfig(cmd, kubeconfigPath)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to deploy phase %s: %w\nOutput: %s", phase.Path, err, string(output))
|
|
}
|
|
|
|
if phase.WaitFor != nil {
|
|
if err := m.waitForRollout(kubeconfigPath, namespace, phase.WaitFor); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
cmd := exec.Command("kubectl", "apply", "-k", appDir)
|
|
tools.WithKubeconfig(cmd, kubeconfigPath)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to deploy app: %w\nOutput: %s", err, string(output))
|
|
}
|
|
}
|
|
|
|
// Post-deploy: restart deployments
|
|
if manifest.Deploy != nil {
|
|
for _, deploymentName := range manifest.Deploy.RestartDeployments {
|
|
cmd := exec.Command("kubectl", "rollout", "restart", fmt.Sprintf("deployment/%s", deploymentName), "-n", namespace)
|
|
tools.WithKubeconfig(cmd, kubeconfigPath)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("failed to restart deployment %s: %w\nOutput: %s", deploymentName, err, string(output))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Post-deploy: wait for rollout
|
|
if manifest.Deploy != nil && manifest.Deploy.WaitForRollout != nil {
|
|
if err := m.waitForRollout(kubeconfigPath, namespace, manifest.Deploy.WaitForRollout); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// waitForRollout waits for a rollout to complete
|
|
func (m *Manager) waitForRollout(kubeconfigPath, namespace string, wait *RolloutWait) error {
|
|
kind := wait.Kind
|
|
if kind == "" {
|
|
kind = "deployment"
|
|
}
|
|
timeout := wait.Timeout
|
|
if timeout == "" {
|
|
timeout = "120s"
|
|
}
|
|
cmd := exec.Command("kubectl", "rollout", "status", fmt.Sprintf("%s/%s", kind, wait.Name), "-n", namespace, fmt.Sprintf("--timeout=%s", timeout))
|
|
tools.WithKubeconfig(cmd, kubeconfigPath)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("failed waiting for %s/%s rollout: %w\nOutput: %s", kind, wait.Name, err, string(output))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// deployWithScript executes an install.sh script for infrastructure packages
|
|
func (m *Manager) deployWithScript(instanceName, appName, appDir, installScript, kubeconfigPath, opID string, broadcaster *operations.Broadcaster) error {
|
|
env := os.Environ()
|
|
env = append(env,
|
|
fmt.Sprintf("WILD_INSTANCE=%s", instanceName),
|
|
fmt.Sprintf("WILD_API_DATA_DIR=%s", m.dataDir),
|
|
fmt.Sprintf("KUBECONFIG=%s", kubeconfigPath),
|
|
)
|
|
|
|
var outputWriter *broadcastWriter
|
|
if opID != "" {
|
|
instanceDir := tools.GetInstancePath(m.dataDir, instanceName)
|
|
logDir := filepath.Join(instanceDir, "operations", opID)
|
|
if err := os.MkdirAll(logDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create log directory: %w", err)
|
|
}
|
|
logFile, err := os.Create(filepath.Join(logDir, "output.log"))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create log file: %w", err)
|
|
}
|
|
defer logFile.Close()
|
|
outputWriter = newBroadcastWriter(logFile, broadcaster, opID)
|
|
if broadcaster != nil {
|
|
broadcaster.Publish(opID, []byte(fmt.Sprintf("Starting deployment of %s...\n", appName)))
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command("/bin/bash", installScript)
|
|
cmd.Dir = appDir
|
|
cmd.Env = env
|
|
|
|
if outputWriter != nil {
|
|
cmd.Stdout = outputWriter
|
|
cmd.Stderr = outputWriter
|
|
err := cmd.Run()
|
|
if broadcaster != nil {
|
|
outputWriter.Flush()
|
|
broadcaster.Close(opID)
|
|
}
|
|
return err
|
|
}
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("deployment script failed: %w\nOutput: %s", err, string(output))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Restart performs a rolling restart of all deployments and statefulsets in an app's namespace
|
|
func (m *Manager) Restart(instanceName, appName string) error {
|
|
kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName)
|
|
namespace := m.ResolveNamespace(instanceName, appName)
|
|
|
|
// Restart deployments
|
|
deployCmd := exec.Command("kubectl", "rollout", "restart", "deployment", "-n", namespace)
|
|
tools.WithKubeconfig(deployCmd, kubeconfigPath)
|
|
if output, err := deployCmd.CombinedOutput(); err != nil {
|
|
// Ignore "no resources found" errors - namespace may only have statefulsets
|
|
if !strings.Contains(string(output), "no resources found") {
|
|
return fmt.Errorf("failed to restart deployments: %w\nOutput: %s", err, string(output))
|
|
}
|
|
}
|
|
|
|
// Restart statefulsets
|
|
stsCmd := exec.Command("kubectl", "rollout", "restart", "statefulset", "-n", namespace)
|
|
tools.WithKubeconfig(stsCmd, kubeconfigPath)
|
|
if output, err := stsCmd.CombinedOutput(); err != nil {
|
|
// Ignore "no resources found" errors - namespace may only have deployments
|
|
if !strings.Contains(string(output), "no resources found") {
|
|
return fmt.Errorf("failed to restart statefulsets: %w\nOutput: %s", err, string(output))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// protectedNamespaces that must never be deleted
|
|
var protectedNamespaces = map[string]bool{
|
|
"kube-system": true, "kube-public": true,
|
|
"kube-node-lease": true, "default": true,
|
|
}
|
|
|
|
// namespaceSharedByOtherApp checks if any other deployed app uses the same namespace
|
|
func (m *Manager) namespaceSharedByOtherApp(instanceName, appName, namespace string) bool {
|
|
deployed, err := m.ListDeployed(instanceName)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, app := range deployed {
|
|
if app.Name != appName && app.Namespace == namespace {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Delete removes an app from the cluster and configuration
|
|
func (m *Manager) Delete(instanceName, appName string) error {
|
|
kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName)
|
|
instancePath := tools.GetInstancePath(m.dataDir, instanceName)
|
|
configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
|
secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName)
|
|
|
|
// Get compiled app manifests from instance directory
|
|
appDir := filepath.Join(instancePath, "apps", appName)
|
|
if !storage.FileExists(appDir) {
|
|
return fmt.Errorf("app %s not found in instance", appName)
|
|
}
|
|
|
|
namespace := m.ResolveNamespace(instanceName, appName)
|
|
|
|
// For protected or shared namespaces, only delete this app's resources (not the namespace)
|
|
if protectedNamespaces[namespace] || m.namespaceSharedByOtherApp(instanceName, appName, namespace) {
|
|
deleteCmd := exec.Command("kubectl", "delete", "-k", appDir, "--ignore-not-found")
|
|
tools.WithKubeconfig(deleteCmd, kubeconfigPath)
|
|
output, err := deleteCmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete app resources: %w\nOutput: %s", err, string(output))
|
|
}
|
|
} else {
|
|
// App owns this namespace exclusively - delete the whole namespace
|
|
deleteNsCmd := exec.Command("kubectl", "delete", "namespace", namespace, "--ignore-not-found")
|
|
tools.WithKubeconfig(deleteNsCmd, kubeconfigPath)
|
|
output, err := deleteNsCmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete namespace: %w\nOutput: %s", err, string(output))
|
|
}
|
|
|
|
// Wait for namespace deletion to complete (timeout after 60s)
|
|
waitCmd := exec.Command("kubectl", "wait", "--for=delete", "namespace", namespace, "--timeout=60s")
|
|
tools.WithKubeconfig(waitCmd, kubeconfigPath)
|
|
_, _ = waitCmd.CombinedOutput() // Ignore errors - namespace might not exist
|
|
}
|
|
|
|
// Delete local app configuration directory
|
|
if err := os.RemoveAll(appDir); err != nil {
|
|
return fmt.Errorf("failed to delete local app directory: %w", err)
|
|
}
|
|
|
|
// Remove app config from config.yaml
|
|
yq := tools.NewYQ()
|
|
configLock := configFile + ".lock"
|
|
if storage.FileExists(configFile) {
|
|
if err := storage.WithLock(configLock, func() error {
|
|
return yq.Delete(configFile, fmt.Sprintf(".apps.%s", appName))
|
|
}); err != nil {
|
|
return fmt.Errorf("failed to remove app config: %w", err)
|
|
}
|
|
}
|
|
|
|
// Remove app secrets from secrets.yaml
|
|
secretsMgr := secrets.NewManager()
|
|
if storage.FileExists(secretsFile) {
|
|
if err := secretsMgr.DeleteSecret(secretsFile, fmt.Sprintf("apps.%s", appName)); err != nil {
|
|
// Don't fail if secret doesn't exist
|
|
if !strings.Contains(err.Error(), "not found") {
|
|
return fmt.Errorf("failed to remove app secrets: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetStatus returns the status of a deployed app
|
|
func (m *Manager) GetStatus(instanceName, appName string) (*DeployedApp, error) {
|
|
kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName)
|
|
instancePath := tools.GetInstancePath(m.dataDir, instanceName)
|
|
appDir := filepath.Join(instancePath, "apps", appName)
|
|
|
|
namespace := m.ResolveNamespace(instanceName, appName)
|
|
|
|
app := &DeployedApp{
|
|
Name: appName,
|
|
Status: "not-added",
|
|
Namespace: namespace,
|
|
}
|
|
|
|
// Check if app was added to instance
|
|
if !storage.FileExists(appDir) {
|
|
return app, nil
|
|
}
|
|
|
|
app.Status = "added"
|
|
|
|
// Load manifest for version and deployment info
|
|
manifestPath := filepath.Join(appDir, "manifest.yaml")
|
|
var manifest AppManifest
|
|
if storage.FileExists(manifestPath) {
|
|
manifestData, _ := os.ReadFile(manifestPath)
|
|
yaml.Unmarshal(manifestData, &manifest)
|
|
app.Version = manifest.Version
|
|
}
|
|
|
|
// Check if namespace exists
|
|
checkNsCmd := exec.Command("kubectl", "get", "namespace", namespace, "-o", "json")
|
|
tools.WithKubeconfig(checkNsCmd, kubeconfigPath)
|
|
nsOutput, err := checkNsCmd.CombinedOutput()
|
|
if err != nil {
|
|
// Namespace doesn't exist - not deployed
|
|
return app, nil
|
|
}
|
|
|
|
// Parse namespace to check if it's active
|
|
var ns struct {
|
|
Status struct {
|
|
Phase string `json:"phase"`
|
|
} `json:"status"`
|
|
}
|
|
if err := yaml.Unmarshal(nsOutput, &ns); err != nil || ns.Status.Phase != "Active" {
|
|
return app, nil
|
|
}
|
|
|
|
// Determine how to check status
|
|
deployName, deployKind := resolveDeploymentResource(&manifest)
|
|
|
|
if deployName != "" {
|
|
// Check specific deployment/daemonset for apps with a known workload
|
|
app.Status = m.getWorkloadStatus(kubeconfigPath, namespace, deployKind, deployName)
|
|
} else {
|
|
// Fall back: check all pods in namespace (for apps that own their namespace)
|
|
app.Status = m.getNamespacePodStatus(kubeconfigPath, namespace)
|
|
}
|
|
|
|
return app, nil
|
|
}
|
|
|
|
// resolveDeploymentResource returns the workload name and kind from the manifest.
|
|
// Returns ("", "") if no deployment resource is specified.
|
|
func resolveDeploymentResource(manifest *AppManifest) (name, kind string) {
|
|
kind = "deployment"
|
|
|
|
if manifest.DeploymentName != "" {
|
|
name = manifest.DeploymentName
|
|
} else if manifest.Deploy != nil && manifest.Deploy.WaitForRollout != nil {
|
|
name = manifest.Deploy.WaitForRollout.Name
|
|
}
|
|
|
|
if manifest.Deploy != nil && manifest.Deploy.WaitForRollout != nil && manifest.Deploy.WaitForRollout.Kind != "" {
|
|
kind = manifest.Deploy.WaitForRollout.Kind
|
|
}
|
|
|
|
return name, kind
|
|
}
|
|
|
|
// getWorkloadStatus checks the status of a specific deployment or daemonset
|
|
func (m *Manager) getWorkloadStatus(kubeconfigPath, namespace, kind, name string) string {
|
|
cmd := exec.Command("kubectl", "get", fmt.Sprintf("%s/%s", kind, name), "-n", namespace, "-o", "json")
|
|
tools.WithKubeconfig(cmd, kubeconfigPath)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return "no-pods"
|
|
}
|
|
|
|
if kind == "daemonset" {
|
|
var ds struct {
|
|
Status struct {
|
|
DesiredNumberScheduled int `json:"desiredNumberScheduled"`
|
|
NumberReady int `json:"numberReady"`
|
|
} `json:"status"`
|
|
}
|
|
if err := json.Unmarshal(output, &ds); err != nil {
|
|
return "error"
|
|
}
|
|
// DaemonSet with 0 desired is valid (e.g., no matching nodes for nvidia GPU plugin)
|
|
if ds.Status.DesiredNumberScheduled == 0 {
|
|
return "running"
|
|
}
|
|
if ds.Status.NumberReady >= ds.Status.DesiredNumberScheduled {
|
|
return "running"
|
|
}
|
|
if ds.Status.NumberReady > 0 {
|
|
return "starting"
|
|
}
|
|
return "unhealthy"
|
|
}
|
|
|
|
// Deployment (or statefulset - same status shape)
|
|
var deploy struct {
|
|
Spec struct {
|
|
Replicas int `json:"replicas"`
|
|
} `json:"spec"`
|
|
Status struct {
|
|
ReadyReplicas int `json:"readyReplicas"`
|
|
} `json:"status"`
|
|
}
|
|
if err := json.Unmarshal(output, &deploy); err != nil {
|
|
return "error"
|
|
}
|
|
|
|
desired := deploy.Spec.Replicas
|
|
if desired == 0 {
|
|
return "running"
|
|
}
|
|
if deploy.Status.ReadyReplicas >= desired {
|
|
return "running"
|
|
}
|
|
if deploy.Status.ReadyReplicas > 0 {
|
|
return "starting"
|
|
}
|
|
return "unhealthy"
|
|
}
|
|
|
|
// getNamespacePodStatus checks status by examining all pods in a namespace.
|
|
// Used for apps that own their namespace (where all pods belong to the app).
|
|
func (m *Manager) getNamespacePodStatus(kubeconfigPath, namespace string) string {
|
|
podsCmd := exec.Command("kubectl", "get", "pods", "-n", namespace, "-o", "json")
|
|
tools.WithKubeconfig(podsCmd, kubeconfigPath)
|
|
podsOutput, err := podsCmd.CombinedOutput()
|
|
if err != nil {
|
|
return "error"
|
|
}
|
|
|
|
var podList struct {
|
|
Items []struct {
|
|
Status struct {
|
|
Phase string `json:"phase"`
|
|
ContainerStatuses []struct {
|
|
Ready bool `json:"ready"`
|
|
} `json:"containerStatuses"`
|
|
} `json:"status"`
|
|
} `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(podsOutput, &podList); err != nil {
|
|
return "error"
|
|
}
|
|
|
|
if len(podList.Items) == 0 {
|
|
return "no-pods"
|
|
}
|
|
|
|
allRunning := true
|
|
allReady := true
|
|
activePods := 0
|
|
for _, pod := range podList.Items {
|
|
if pod.Status.Phase == "Succeeded" {
|
|
continue
|
|
}
|
|
activePods++
|
|
if pod.Status.Phase != "Running" {
|
|
allRunning = false
|
|
}
|
|
for _, cs := range pod.Status.ContainerStatuses {
|
|
if !cs.Ready {
|
|
allReady = false
|
|
}
|
|
}
|
|
}
|
|
|
|
if activePods == 0 {
|
|
return "no-pods"
|
|
}
|
|
|
|
if allRunning && allReady {
|
|
return "running"
|
|
} else if allRunning {
|
|
return "starting"
|
|
}
|
|
return "unhealthy"
|
|
}
|
|
|
|
// GetEnhanced returns enhanced app information with runtime status
|
|
func (m *Manager) GetEnhanced(instanceName, appName string) (*EnhancedApp, error) {
|
|
kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName)
|
|
instancePath := tools.GetInstancePath(m.dataDir, instanceName)
|
|
configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
|
appDir := filepath.Join(instancePath, "apps", appName)
|
|
|
|
namespace := m.ResolveNamespace(instanceName, appName)
|
|
|
|
enhanced := &EnhancedApp{
|
|
Name: appName,
|
|
Status: "not-added",
|
|
Namespace: namespace,
|
|
}
|
|
|
|
// Check if app was added to instance
|
|
if !storage.FileExists(appDir) {
|
|
return enhanced, nil
|
|
}
|
|
|
|
enhanced.Status = "added"
|
|
|
|
// Load manifest
|
|
manifestPath := filepath.Join(appDir, "manifest.yaml")
|
|
if storage.FileExists(manifestPath) {
|
|
manifestData, _ := os.ReadFile(manifestPath)
|
|
var manifest AppManifest
|
|
if yaml.Unmarshal(manifestData, &manifest) == nil {
|
|
enhanced.Version = manifest.Version
|
|
enhanced.Description = manifest.Description
|
|
enhanced.Icon = manifest.Icon
|
|
enhanced.Manifest = &manifest
|
|
}
|
|
}
|
|
|
|
// Note: README content is now served via dedicated /readme endpoint
|
|
// No need to populate readme/documentation fields here
|
|
|
|
// Load config
|
|
yq := tools.NewYQ()
|
|
configJSON, err := yq.Get(configFile, fmt.Sprintf(".apps.%s | @json", appName))
|
|
if err == nil && configJSON != "" && configJSON != "null" {
|
|
var config map[string]interface{}
|
|
if json.Unmarshal([]byte(configJSON), &config) == nil {
|
|
enhanced.Config = config
|
|
}
|
|
}
|
|
|
|
// Check if namespace exists
|
|
checkNsCmd := exec.Command("kubectl", "get", "namespace", namespace, "-o", "json")
|
|
tools.WithKubeconfig(checkNsCmd, kubeconfigPath)
|
|
nsOutput, err := checkNsCmd.CombinedOutput()
|
|
if err != nil {
|
|
// Namespace doesn't exist - not deployed
|
|
return enhanced, nil
|
|
}
|
|
|
|
// Parse namespace to check if it's active
|
|
var ns struct {
|
|
Status struct {
|
|
Phase string `json:"phase"`
|
|
} `json:"status"`
|
|
}
|
|
if err := json.Unmarshal(nsOutput, &ns); err != nil || ns.Status.Phase != "Active" {
|
|
return enhanced, nil
|
|
}
|
|
|
|
enhanced.Status = "deployed"
|
|
|
|
// Get URL (ingress)
|
|
enhanced.URL = m.getAppURL(kubeconfigPath, namespace)
|
|
|
|
// Determine status from specific workload if available
|
|
deployName, deployKind := resolveDeploymentResource(enhanced.Manifest)
|
|
if deployName != "" {
|
|
enhanced.Status = m.getWorkloadStatus(kubeconfigPath, namespace, deployKind, deployName)
|
|
}
|
|
|
|
// Get runtime status (for UI pod display)
|
|
runtime, err := m.getRuntimeStatus(kubeconfigPath, namespace)
|
|
if err == nil {
|
|
enhanced.Runtime = runtime
|
|
|
|
// If no specific workload was checked, derive status from pods
|
|
if deployName == "" && len(runtime.Pods) > 0 {
|
|
allRunning := true
|
|
allReady := true
|
|
for _, pod := range runtime.Pods {
|
|
if pod.Status != "Running" {
|
|
allRunning = false
|
|
}
|
|
parts := strings.Split(pod.Ready, "/")
|
|
if len(parts) == 2 && parts[0] != parts[1] {
|
|
allReady = false
|
|
}
|
|
}
|
|
|
|
if allRunning && allReady {
|
|
enhanced.Status = "running"
|
|
} else if allRunning {
|
|
enhanced.Status = "starting"
|
|
} else {
|
|
enhanced.Status = "unhealthy"
|
|
}
|
|
}
|
|
}
|
|
|
|
return enhanced, nil
|
|
}
|
|
|
|
// GetEnhancedStatus returns just the runtime status for an app
|
|
func (m *Manager) GetEnhancedStatus(instanceName, appName string) (*RuntimeStatus, error) {
|
|
kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName)
|
|
namespace := m.ResolveNamespace(instanceName, appName)
|
|
|
|
// Check if namespace exists
|
|
checkNsCmd := exec.Command("kubectl", "get", "namespace", namespace, "-o", "json")
|
|
tools.WithKubeconfig(checkNsCmd, kubeconfigPath)
|
|
if err := checkNsCmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("namespace not found or not deployed")
|
|
}
|
|
|
|
return m.getRuntimeStatus(kubeconfigPath, namespace)
|
|
}
|
|
|
|
// GetAppManifest reads and parses the manifest.yaml for an app from the apps directory
|
|
func (m *Manager) GetAppManifest(appName string) (*AppManifest, error) {
|
|
if m.appsDir == "" {
|
|
return nil, fmt.Errorf("apps directory not configured")
|
|
}
|
|
|
|
manifestPath := filepath.Join(m.appsDir, appName, "manifest.yaml")
|
|
if !storage.FileExists(manifestPath) {
|
|
return nil, fmt.Errorf("manifest not found for app %s", appName)
|
|
}
|
|
|
|
data, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read manifest: %w", err)
|
|
}
|
|
|
|
var manifest AppManifest
|
|
if err := yaml.Unmarshal(data, &manifest); err != nil {
|
|
return nil, fmt.Errorf("failed to parse manifest: %w", err)
|
|
}
|
|
|
|
return &manifest, nil
|
|
}
|
|
|
|
// getRuntimeStatus fetches runtime information from kubernetes
|
|
func (m *Manager) getRuntimeStatus(kubeconfigPath, namespace string) (*RuntimeStatus, error) {
|
|
kubectl := tools.NewKubectl(kubeconfigPath)
|
|
|
|
runtime := &RuntimeStatus{}
|
|
|
|
// Get pods (with detailed info for app status display)
|
|
pods, err := kubectl.GetPods(namespace, true)
|
|
if err == nil {
|
|
runtime.Pods = pods
|
|
}
|
|
|
|
// Get replicas
|
|
replicas, err := kubectl.GetReplicas(namespace)
|
|
if err == nil && (replicas.Desired > 0 || replicas.Current > 0) {
|
|
runtime.Replicas = replicas
|
|
}
|
|
|
|
// Get resources
|
|
resources, err := kubectl.GetResources(namespace)
|
|
if err == nil {
|
|
runtime.Resources = resources
|
|
}
|
|
|
|
// Get recent events (last 10)
|
|
events, err := kubectl.GetRecentEvents(namespace, 10)
|
|
if err == nil {
|
|
runtime.RecentEvents = events
|
|
}
|
|
|
|
return runtime, nil
|
|
}
|
|
|
|
// Update updates an app from its source package
|
|
func (m *Manager) Update(instanceName, appName string) error {
|
|
instancePath := tools.GetInstancePath(m.dataDir, instanceName)
|
|
appDestDir := filepath.Join(instancePath, "apps", appName)
|
|
packageDir := filepath.Join(appDestDir, ".package")
|
|
|
|
// Check if .package exists (if not, it's a custom app)
|
|
if !storage.FileExists(packageDir) {
|
|
return fmt.Errorf("app %s is custom or not installed (no package source)", appName)
|
|
}
|
|
|
|
// Read manifest to get source
|
|
manifestPath := filepath.Join(appDestDir, "manifest.yaml")
|
|
manifestData, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read manifest: %w", err)
|
|
}
|
|
|
|
var manifest AppManifest
|
|
if err := yaml.Unmarshal(manifestData, &manifest); err != nil {
|
|
return fmt.Errorf("failed to parse manifest: %w", err)
|
|
}
|
|
|
|
// Parse source URI (e.g., "file:///path/to/wild-directory/postgres")
|
|
sourceParts := strings.Split(manifest.Source, "://")
|
|
if len(sourceParts) != 2 {
|
|
return fmt.Errorf("invalid source format: %s", manifest.Source)
|
|
}
|
|
|
|
var sourceAppDir string
|
|
switch sourceParts[0] {
|
|
case "file":
|
|
// Local filesystem path
|
|
sourceAppDir = sourceParts[1]
|
|
case "git+https", "git+http", "git+ssh":
|
|
// Git repository - not yet implemented
|
|
return fmt.Errorf("git source not yet supported: %s", manifest.Source)
|
|
default:
|
|
return fmt.Errorf("unsupported source protocol: %s", sourceParts[0])
|
|
}
|
|
|
|
// Copy new version to temp directory
|
|
tempDir := filepath.Join(appDestDir, ".package.new")
|
|
if err := storage.EnsureDir(tempDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create temp directory: %w", err)
|
|
}
|
|
defer os.RemoveAll(tempDir) // Clean up temp dir
|
|
|
|
// Copy from source
|
|
entries, err := os.ReadDir(sourceAppDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read source directory: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
subSrcDir := filepath.Join(sourceAppDir, entry.Name())
|
|
subDstDir := filepath.Join(tempDir, entry.Name())
|
|
if err := copyDir(subSrcDir, subDstDir); err != nil {
|
|
return fmt.Errorf("failed to copy directory %s: %w", entry.Name(), err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
sourcePath := filepath.Join(sourceAppDir, entry.Name())
|
|
tempPath := filepath.Join(tempDir, entry.Name())
|
|
|
|
data, err := os.ReadFile(sourcePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read %s: %w", entry.Name(), err)
|
|
}
|
|
if err := storage.WriteFile(tempPath, data, 0644); err != nil {
|
|
return fmt.Errorf("failed to write %s: %w", entry.Name(), err)
|
|
}
|
|
}
|
|
|
|
// Replace .package with new version, keeping old as backup
|
|
oldPackageDir := packageDir + ".old"
|
|
os.RemoveAll(oldPackageDir) // Clean up any leftover backup
|
|
if err := os.Rename(packageDir, oldPackageDir); err != nil {
|
|
return fmt.Errorf("failed to backup old package: %w", err)
|
|
}
|
|
if err := os.Rename(tempDir, packageDir); err != nil {
|
|
// Restore backup on failure
|
|
os.Rename(oldPackageDir, packageDir)
|
|
return fmt.Errorf("failed to update package: %w", err)
|
|
}
|
|
|
|
configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
|
secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName)
|
|
|
|
// rollback restores the old package if anything fails after the swap
|
|
rollback := func() {
|
|
os.RemoveAll(packageDir)
|
|
os.Rename(oldPackageDir, packageDir)
|
|
}
|
|
|
|
// Read the new manifest
|
|
newManifestPath := filepath.Join(packageDir, "manifest.yaml")
|
|
newManifestData, err := os.ReadFile(newManifestPath)
|
|
if err != nil {
|
|
rollback()
|
|
return fmt.Errorf("failed to read new manifest: %w", err)
|
|
}
|
|
|
|
var newManifest AppManifest
|
|
if err := yaml.Unmarshal(newManifestData, &newManifest); err != nil {
|
|
rollback()
|
|
return fmt.Errorf("failed to parse new manifest: %w", err)
|
|
}
|
|
|
|
// Merge new defaultConfig keys into config.yaml (skips existing values)
|
|
configLock := configFile + ".lock"
|
|
if err := storage.WithLock(configLock, func() error {
|
|
return processConfigInOrder(newManifestPath, appName, configFile)
|
|
}); err != nil {
|
|
rollback()
|
|
return fmt.Errorf("failed to merge new config: %w", err)
|
|
}
|
|
|
|
// Generate any new defaultSecrets that don't exist yet
|
|
if err := ensureDefaultSecrets(newManifest.DefaultSecrets, appName, configFile, secretsFile); err != nil {
|
|
rollback()
|
|
return err
|
|
}
|
|
|
|
// Update local manifest with new version while preserving source and installedAs
|
|
newManifest.Source = manifest.Source
|
|
if len(manifest.Requires) > 0 {
|
|
installedAsMap := make(map[string]string)
|
|
for _, req := range manifest.Requires {
|
|
key := req.Name
|
|
if req.Alias != "" {
|
|
key = req.Alias
|
|
}
|
|
if req.InstalledAs != "" {
|
|
installedAsMap[key] = req.InstalledAs
|
|
}
|
|
}
|
|
for i := range newManifest.Requires {
|
|
key := newManifest.Requires[i].Name
|
|
if newManifest.Requires[i].Alias != "" {
|
|
key = newManifest.Requires[i].Alias
|
|
}
|
|
if installedAs, ok := installedAsMap[key]; ok {
|
|
newManifest.Requires[i].InstalledAs = installedAs
|
|
}
|
|
}
|
|
}
|
|
|
|
manifestYAML, err := yaml.Marshal(newManifest)
|
|
if err != nil {
|
|
rollback()
|
|
return fmt.Errorf("failed to marshal updated manifest: %w", err)
|
|
}
|
|
if err := storage.WriteFile(manifestPath, manifestYAML, 0644); err != nil {
|
|
rollback()
|
|
return fmt.Errorf("failed to write updated manifest: %w", err)
|
|
}
|
|
|
|
// Re-compile from .package to app dir
|
|
if err := m.compileFromPackage(appName, appDestDir, packageDir, configFile, secretsFile); err != nil {
|
|
rollback()
|
|
return fmt.Errorf("failed to recompile app templates: %w", err)
|
|
}
|
|
|
|
// Success - clean up backup
|
|
os.RemoveAll(oldPackageDir)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Eject converts an app from package-managed to custom
|
|
func (m *Manager) Eject(instanceName, appName string) error {
|
|
instancePath := tools.GetInstancePath(m.dataDir, instanceName)
|
|
appDestDir := filepath.Join(instancePath, "apps", appName)
|
|
packageDir := filepath.Join(appDestDir, ".package")
|
|
|
|
// Remove .package directory
|
|
if err := os.RemoveAll(packageDir); err != nil {
|
|
return fmt.Errorf("failed to remove package directory: %w", err)
|
|
}
|
|
|
|
// Update manifest to remove source
|
|
manifestPath := filepath.Join(appDestDir, "manifest.yaml")
|
|
manifestData, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read manifest: %w", err)
|
|
}
|
|
|
|
var manifest AppManifest
|
|
if err := yaml.Unmarshal(manifestData, &manifest); err != nil {
|
|
return fmt.Errorf("failed to parse manifest: %w", err)
|
|
}
|
|
|
|
// Remove source field
|
|
manifest.Source = ""
|
|
|
|
// Save updated manifest
|
|
manifestYAML, err := yaml.Marshal(manifest)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal manifest: %w", err)
|
|
}
|
|
if err := storage.WriteFile(manifestPath, manifestYAML, 0644); err != nil {
|
|
return fmt.Errorf("failed to write manifest: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateConfig updates an app's configuration and recompiles if needed
|
|
func (m *Manager) UpdateConfig(instanceName, appName string, config map[string]interface{}) error {
|
|
instancePath := tools.GetInstancePath(m.dataDir, instanceName)
|
|
configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
|
secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName)
|
|
appDestDir := filepath.Join(instancePath, "apps", appName)
|
|
packageDir := filepath.Join(appDestDir, ".package")
|
|
|
|
// Update config
|
|
yq := tools.NewYQ()
|
|
configLock := configFile + ".lock"
|
|
|
|
if err := storage.WithLock(configLock, func() error {
|
|
for key, value := range config {
|
|
keyPath := fmt.Sprintf(".apps.%s.%s", appName, key)
|
|
// Use setNestedConfig to handle both simple values and nested objects
|
|
if err := setNestedConfig(yq, configFile, keyPath, value); err != nil {
|
|
return fmt.Errorf("failed to set config %s: %w", key, err)
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Re-compile if app has .package
|
|
if storage.FileExists(packageDir) {
|
|
if err := m.compileFromPackage(appName, appDestDir, packageDir, configFile, secretsFile); err != nil {
|
|
return fmt.Errorf("failed to recompile app templates: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// copyDir recursively copies a directory and its contents
|
|
func copyDir(src, dst string) error {
|
|
if err := storage.EnsureDir(dst, 0755); err != nil {
|
|
return fmt.Errorf("failed to create directory %s: %w", dst, err)
|
|
}
|
|
|
|
entries, err := os.ReadDir(src)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read directory %s: %w", src, err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
srcPath := filepath.Join(src, entry.Name())
|
|
dstPath := filepath.Join(dst, entry.Name())
|
|
|
|
if entry.IsDir() {
|
|
if err := copyDir(srcPath, dstPath); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
|
|
data, err := os.ReadFile(srcPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read %s: %w", srcPath, err)
|
|
}
|
|
if err := storage.WriteFile(dstPath, data, 0644); err != nil {
|
|
return fmt.Errorf("failed to write %s: %w", dstPath, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// compileFromPackage compiles templates from .package directory to the app directory
|
|
func (m *Manager) compileFromPackage(appName, appDestDir, packageDir, configFile, secretsFile string) error {
|
|
gomplate := tools.NewGomplate()
|
|
yq := tools.NewYQ()
|
|
|
|
tempConfigFile := filepath.Join(appDestDir, ".config.tmp.yaml")
|
|
appConfigYAML, _ := yq.Get(configFile, fmt.Sprintf(".apps.%s", appName))
|
|
if appConfigYAML != "" && appConfigYAML != "null" {
|
|
if err := storage.WriteFile(tempConfigFile, []byte(appConfigYAML), 0644); err != nil {
|
|
return fmt.Errorf("failed to write temp config: %w", err)
|
|
}
|
|
defer os.Remove(tempConfigFile)
|
|
}
|
|
|
|
tempSecretsFile := filepath.Join(appDestDir, ".secrets.tmp.yaml")
|
|
appSecretsYAML, err := yq.Get(secretsFile, fmt.Sprintf(".apps.%s", appName))
|
|
if err == nil && appSecretsYAML != "" && appSecretsYAML != "null" {
|
|
if err := storage.WriteFile(tempSecretsFile, []byte(appSecretsYAML), 0644); err != nil {
|
|
return fmt.Errorf("failed to write temp secrets: %w", err)
|
|
}
|
|
defer os.Remove(tempSecretsFile)
|
|
} else {
|
|
if err := storage.WriteFile(tempSecretsFile, []byte("apps: {}"), 0644); err != nil {
|
|
return fmt.Errorf("failed to write empty temp secrets: %w", err)
|
|
}
|
|
defer os.Remove(tempSecretsFile)
|
|
}
|
|
|
|
context := map[string]string{
|
|
".": tempConfigFile,
|
|
"secrets": tempSecretsFile,
|
|
}
|
|
|
|
packageEntries, err := os.ReadDir(packageDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read package directory: %w", err)
|
|
}
|
|
|
|
for _, entry := range packageEntries {
|
|
if entry.Name() == "manifest.yaml" {
|
|
continue
|
|
}
|
|
|
|
sourcePath := filepath.Join(packageDir, entry.Name())
|
|
destPath := filepath.Join(appDestDir, entry.Name())
|
|
|
|
if entry.IsDir() {
|
|
if err := compileDir(sourcePath, destPath, gomplate, context); err != nil {
|
|
return fmt.Errorf("failed to compile directory %s: %w", entry.Name(), err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if err := gomplate.RenderWithContext(sourcePath, destPath, context); err != nil {
|
|
return fmt.Errorf("failed to compile %s: %w", entry.Name(), err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// compileDir recursively renders gomplate templates in a directory
|
|
func compileDir(srcDir, dstDir string, gomplate *tools.Gomplate, context map[string]string) error {
|
|
if err := storage.EnsureDir(dstDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create directory %s: %w", dstDir, err)
|
|
}
|
|
|
|
entries, err := os.ReadDir(srcDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read directory %s: %w", srcDir, err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
srcPath := filepath.Join(srcDir, entry.Name())
|
|
dstPath := filepath.Join(dstDir, entry.Name())
|
|
|
|
if entry.IsDir() {
|
|
if err := compileDir(srcPath, dstPath, gomplate, context); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
|
|
if err := gomplate.RenderWithContext(srcPath, dstPath, context); err != nil {
|
|
return fmt.Errorf("failed to compile %s: %w", entry.Name(), err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Compile recompiles an app's templates from .package to the app directory
|
|
func (m *Manager) Compile(instanceName, appName string) error {
|
|
instancePath := tools.GetInstancePath(m.dataDir, instanceName)
|
|
configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
|
secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName)
|
|
appDestDir := filepath.Join(instancePath, "apps", appName)
|
|
packageDir := filepath.Join(appDestDir, ".package")
|
|
|
|
if !storage.FileExists(packageDir) {
|
|
return fmt.Errorf("app %s has no package source (custom or not installed)", appName)
|
|
}
|
|
|
|
return m.compileFromPackage(appName, appDestDir, packageDir, configFile, secretsFile)
|
|
}
|
|
|
|
// Fetch re-fetches an app's files from the source directory
|
|
func (m *Manager) Fetch(instanceName, appName string) error {
|
|
instancePath := tools.GetInstancePath(m.dataDir, instanceName)
|
|
appDestDir := filepath.Join(instancePath, "apps", appName)
|
|
manifestPath := filepath.Join(appDestDir, "manifest.yaml")
|
|
|
|
if !storage.FileExists(manifestPath) {
|
|
return fmt.Errorf("app %s not found in instance", appName)
|
|
}
|
|
|
|
// Read manifest to get source
|
|
manifestData, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read manifest: %w", err)
|
|
}
|
|
|
|
var manifest AppManifest
|
|
if err := yaml.Unmarshal(manifestData, &manifest); err != nil {
|
|
return fmt.Errorf("failed to parse manifest: %w", err)
|
|
}
|
|
|
|
if manifest.Source == "" {
|
|
return fmt.Errorf("app %s has no source (ejected or custom)", appName)
|
|
}
|
|
|
|
// Parse source URI
|
|
sourceParts := strings.Split(manifest.Source, "://")
|
|
if len(sourceParts) != 2 || sourceParts[0] != "file" {
|
|
return fmt.Errorf("unsupported source: %s", manifest.Source)
|
|
}
|
|
sourceDir := sourceParts[1]
|
|
|
|
// Update .package directory with fresh files from source
|
|
packageDir := filepath.Join(appDestDir, ".package")
|
|
if err := storage.EnsureDir(packageDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create package directory: %w", err)
|
|
}
|
|
|
|
entries, err := os.ReadDir(sourceDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read source directory: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
sourcePath := filepath.Join(sourceDir, entry.Name())
|
|
packagePath := filepath.Join(packageDir, entry.Name())
|
|
|
|
if entry.IsDir() {
|
|
if err := copyDir(sourcePath, packagePath); err != nil {
|
|
return fmt.Errorf("failed to copy directory %s: %w", entry.Name(), err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
data, err := os.ReadFile(sourcePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read %s: %w", entry.Name(), err)
|
|
}
|
|
if err := storage.WriteFile(packagePath, data, 0644); err != nil {
|
|
return fmt.Errorf("failed to write %s: %w", entry.Name(), err)
|
|
}
|
|
}
|
|
|
|
// Update the compiled manifest with metadata from the source manifest
|
|
// Preserves instance-specific fields (source, installedAs) while updating
|
|
// package metadata (version, scripts, deploy, description, icon)
|
|
sourceManifestPath := filepath.Join(packageDir, "manifest.yaml")
|
|
if sourceData, err := os.ReadFile(sourceManifestPath); err == nil {
|
|
var sourceManifest AppManifest
|
|
if err := yaml.Unmarshal(sourceData, &sourceManifest); err == nil {
|
|
manifest.Version = sourceManifest.Version
|
|
manifest.Description = sourceManifest.Description
|
|
manifest.Icon = sourceManifest.Icon
|
|
manifest.Scripts = sourceManifest.Scripts
|
|
manifest.Deploy = sourceManifest.Deploy
|
|
manifestYAML, err := yaml.Marshal(manifest)
|
|
if err == nil {
|
|
storage.WriteFile(manifestPath, manifestYAML, 0644)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-compile to update the app directory from the fetched package
|
|
configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
|
secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName)
|
|
if err := m.compileFromPackage(appName, appDestDir, packageDir, configFile, secretsFile); err != nil {
|
|
return fmt.Errorf("failed to compile after fetch: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Install orchestrates add, compile, and deploy in sequence (for service-style installs)
|
|
func (m *Manager) Install(instanceName, appName string, fetch, deploy bool, config map[string]interface{}, requiredAppMappings map[string]string, opID string, broadcaster *operations.Broadcaster) error {
|
|
instancePath := tools.GetInstancePath(m.dataDir, instanceName)
|
|
appDestDir := filepath.Join(instancePath, "apps", appName)
|
|
|
|
if fetch || !storage.FileExists(appDestDir) {
|
|
// Add (which includes fetch + compile)
|
|
if err := m.Add(instanceName, appName, config, requiredAppMappings); err != nil {
|
|
return fmt.Errorf("add failed: %w", err)
|
|
}
|
|
} else {
|
|
// Just recompile
|
|
packageDir := filepath.Join(appDestDir, ".package")
|
|
if storage.FileExists(packageDir) {
|
|
configFile := tools.GetInstanceConfigPath(m.dataDir, instanceName)
|
|
secretsFile := tools.GetInstanceSecretsPath(m.dataDir, instanceName)
|
|
if err := m.compileFromPackage(appName, appDestDir, packageDir, configFile, secretsFile); err != nil {
|
|
return fmt.Errorf("compile failed: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Deploy if requested
|
|
if deploy {
|
|
if err := m.Deploy(instanceName, appName, opID, broadcaster); err != nil {
|
|
return fmt.Errorf("deploy failed: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getAppURL extracts the ingress URL for an app
|
|
func (m *Manager) getAppURL(kubeconfigPath, namespace string) string {
|
|
// Try Traefik IngressRoute first
|
|
ingressCmd := exec.Command("kubectl", "get", "ingressroute", "-n", namespace, "-o", "json")
|
|
tools.WithKubeconfig(ingressCmd, kubeconfigPath)
|
|
ingressOutput, err := ingressCmd.CombinedOutput()
|
|
|
|
if err == nil {
|
|
var ingressList struct {
|
|
Items []struct {
|
|
Spec struct {
|
|
Routes []struct {
|
|
Match string `json:"match"`
|
|
} `json:"routes"`
|
|
} `json:"spec"`
|
|
} `json:"items"`
|
|
}
|
|
if json.Unmarshal(ingressOutput, &ingressList) == nil && len(ingressList.Items) > 0 {
|
|
if len(ingressList.Items[0].Spec.Routes) > 0 {
|
|
match := ingressList.Items[0].Spec.Routes[0].Match
|
|
// Parse Host(`domain.com`) format
|
|
if strings.Contains(match, "Host(`") {
|
|
start := strings.Index(match, "Host(`") + 6
|
|
end := strings.Index(match[start:], "`")
|
|
if end > 0 {
|
|
host := match[start : start+end]
|
|
return "https://" + host
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no IngressRoute, try standard Ingress
|
|
ingressCmd = exec.Command("kubectl", "get", "ingress", "-n", namespace, "-o", "json")
|
|
tools.WithKubeconfig(ingressCmd, kubeconfigPath)
|
|
ingressOutput, err = ingressCmd.CombinedOutput()
|
|
|
|
if err == nil {
|
|
var ingressList struct {
|
|
Items []struct {
|
|
Spec struct {
|
|
Rules []struct {
|
|
Host string `json:"host"`
|
|
} `json:"rules"`
|
|
} `json:"spec"`
|
|
} `json:"items"`
|
|
}
|
|
if json.Unmarshal(ingressOutput, &ingressList) == nil && len(ingressList.Items) > 0 {
|
|
if len(ingressList.Items[0].Spec.Rules) > 0 {
|
|
host := ingressList.Items[0].Spec.Rules[0].Host
|
|
if host != "" {
|
|
return "https://" + host
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// processUserConfig processes user-provided config values, compiling any templates
|
|
// Reuses existing processValueNode logic by converting to YAML and back
|
|
func processUserConfig(config map[string]interface{}, appName, configFile string, gomplate *tools.Gomplate) (map[string]interface{}, error) {
|
|
// Convert map to YAML bytes
|
|
configYAML, err := yaml.Marshal(config)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal config: %w", err)
|
|
}
|
|
|
|
// Parse into yaml.Node to use existing processValueNode
|
|
var node yaml.Node
|
|
if err := yaml.Unmarshal(configYAML, &node); err != nil {
|
|
return nil, fmt.Errorf("failed to parse config: %w", err)
|
|
}
|
|
|
|
// Process using existing template compilation logic
|
|
// Note: processValueNode expects the root mapping node's content
|
|
if node.Kind != yaml.DocumentNode || len(node.Content) == 0 {
|
|
return config, nil
|
|
}
|
|
|
|
processed, err := processValueNode(node.Content[0], appName, configFile, nil, gomplate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Convert result back to map
|
|
result, ok := processed.(map[string]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("unexpected result type from processValueNode: %T", processed)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// processConfigInOrder processes config keys in the order they appear in the manifest YAML
|
|
// processValueNode recursively processes a yaml.Node value, compiling templates in all scalar (leaf) nodes
|
|
func processValueNode(node *yaml.Node, appName, configFile string, appContext map[string]interface{}, gomplate *tools.Gomplate) (interface{}, error) {
|
|
switch node.Kind {
|
|
case yaml.ScalarNode:
|
|
value := node.Value
|
|
// Process templates if value contains {{
|
|
if strings.Contains(value, "{{") {
|
|
// Create merged context file
|
|
mergedContextFile := filepath.Join(filepath.Dir(configFile), fmt.Sprintf(".merged.%s.tmp.yaml", appName))
|
|
defer os.Remove(mergedContextFile)
|
|
|
|
// Load root config
|
|
rootData, err := os.ReadFile(configFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read config: %w", err)
|
|
}
|
|
|
|
var rootConfig map[string]interface{}
|
|
if err := yaml.Unmarshal(rootData, &rootConfig); err != nil {
|
|
return nil, fmt.Errorf("failed to parse config: %w", err)
|
|
}
|
|
|
|
// Merge app context under the "app" key
|
|
rootConfig["app"] = appContext
|
|
|
|
// Write merged config
|
|
mergedYAML, err := yaml.Marshal(rootConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal merged config: %w", err)
|
|
}
|
|
if err := storage.WriteFile(mergedContextFile, mergedYAML, 0644); err != nil {
|
|
return nil, fmt.Errorf("failed to write merged config: %w", err)
|
|
}
|
|
|
|
// Process template with merged context
|
|
args := []string{
|
|
"-i", value,
|
|
"-c", fmt.Sprintf(".=%s", mergedContextFile),
|
|
}
|
|
compiled, err := gomplate.Exec(args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to compile template: %w", err)
|
|
}
|
|
return strings.TrimSpace(compiled), nil
|
|
}
|
|
return value, nil
|
|
|
|
case yaml.SequenceNode:
|
|
// Handle arrays - process each element
|
|
var arr []interface{}
|
|
for _, item := range node.Content {
|
|
processed, err := processValueNode(item, appName, configFile, appContext, gomplate)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
arr = append(arr, processed)
|
|
}
|
|
return arr, nil
|
|
|
|
case yaml.MappingNode:
|
|
// Handle nested objects - recursively process each key-value pair
|
|
result := make(map[string]interface{})
|
|
for i := 0; i < len(node.Content); i += 2 {
|
|
keyNode := node.Content[i]
|
|
valueNode := node.Content[i+1]
|
|
|
|
key := keyNode.Value
|
|
processed, err := processValueNode(valueNode, appName, configFile, appContext, gomplate)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to process nested key %s: %w", key, err)
|
|
}
|
|
result[key] = processed
|
|
}
|
|
return result, nil
|
|
|
|
default:
|
|
return node.Value, nil
|
|
}
|
|
}
|
|
|
|
func processConfigInOrder(manifestPath string, appName string, configFile string) error {
|
|
// Read the manifest file directly to preserve order
|
|
manifestData, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read manifest: %w", err)
|
|
}
|
|
|
|
// Parse YAML preserving order
|
|
var node yaml.Node
|
|
if err := yaml.Unmarshal(manifestData, &node); err != nil {
|
|
return fmt.Errorf("failed to parse manifest YAML: %w", err)
|
|
}
|
|
|
|
// Find defaultConfig node
|
|
var defaultConfigNode *yaml.Node
|
|
if len(node.Content) > 0 && node.Content[0].Kind == yaml.MappingNode {
|
|
for i := 0; i < len(node.Content[0].Content); i += 2 {
|
|
if node.Content[0].Content[i].Value == "defaultConfig" {
|
|
defaultConfigNode = node.Content[0].Content[i+1]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if defaultConfigNode == nil || defaultConfigNode.Kind != yaml.MappingNode {
|
|
return nil // No defaultConfig to process
|
|
}
|
|
|
|
yq := tools.NewYQ()
|
|
gomplate := tools.NewGomplate()
|
|
|
|
// Build up app context as we process values
|
|
appContext := make(map[string]interface{})
|
|
|
|
// Process each config key in order
|
|
for i := 0; i < len(defaultConfigNode.Content); i += 2 {
|
|
keyNode := defaultConfigNode.Content[i]
|
|
valueNode := defaultConfigNode.Content[i+1]
|
|
|
|
key := keyNode.Value
|
|
keyPath := fmt.Sprintf(".apps.%s.%s", appName, key)
|
|
|
|
// Check if already exists
|
|
existing, _ := yq.Get(configFile, keyPath)
|
|
if existing != "" && existing != "null" {
|
|
// Parse the existing value and add to context for later references
|
|
var existingValue interface{}
|
|
if err := yaml.Unmarshal([]byte(existing), &existingValue); err == nil {
|
|
appContext[key] = existingValue
|
|
} else {
|
|
appContext[key] = existing
|
|
}
|
|
continue // Skip existing values
|
|
}
|
|
|
|
// Recursively process the value node, compiling templates in all leaf nodes
|
|
value, err := processValueNode(valueNode, appName, configFile, appContext, gomplate)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to process config key %s: %w", key, err)
|
|
}
|
|
|
|
// Add processed value to context for future references
|
|
appContext[key] = value
|
|
|
|
// Set the config value in the actual config file
|
|
if err := setNestedConfig(yq, configFile, keyPath, value); err != nil {
|
|
return fmt.Errorf("failed to set config %s: %w", key, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RunScript executes a named script defined in the app's manifest
|
|
func (m *Manager) RunScript(instanceName, appName, scriptName, opID string, broadcaster *operations.Broadcaster) error {
|
|
appDir := filepath.Join(m.dataDir, "instances", instanceName, "apps", appName)
|
|
manifestPath := filepath.Join(appDir, "manifest.yaml")
|
|
|
|
manifestData, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read manifest: %w", err)
|
|
}
|
|
|
|
var manifest AppManifest
|
|
if err := yaml.Unmarshal(manifestData, &manifest); err != nil {
|
|
return fmt.Errorf("failed to parse manifest: %w", err)
|
|
}
|
|
|
|
// Find the script
|
|
var script *Script
|
|
for _, s := range manifest.Scripts {
|
|
if s.Name == scriptName {
|
|
script = &s
|
|
break
|
|
}
|
|
}
|
|
if script == nil {
|
|
return fmt.Errorf("script '%s' not found in manifest", scriptName)
|
|
}
|
|
|
|
scriptPath := filepath.Join(appDir, script.Path)
|
|
if !storage.FileExists(scriptPath) {
|
|
return fmt.Errorf("script file not found: %s", script.Path)
|
|
}
|
|
|
|
kubeconfigPath := tools.GetKubeconfigPath(m.dataDir, instanceName)
|
|
|
|
env := os.Environ()
|
|
env = append(env,
|
|
fmt.Sprintf("WILD_INSTANCE=%s", instanceName),
|
|
fmt.Sprintf("WILD_API_DATA_DIR=%s", m.dataDir),
|
|
fmt.Sprintf("KUBECONFIG=%s", kubeconfigPath),
|
|
)
|
|
|
|
var outputWriter *broadcastWriter
|
|
if opID != "" {
|
|
instanceDir := tools.GetInstancePath(m.dataDir, instanceName)
|
|
logDir := filepath.Join(instanceDir, "operations", opID)
|
|
if err := os.MkdirAll(logDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create log directory: %w", err)
|
|
}
|
|
logFile, err := os.Create(filepath.Join(logDir, "output.log"))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create log file: %w", err)
|
|
}
|
|
defer logFile.Close()
|
|
outputWriter = newBroadcastWriter(logFile, broadcaster, opID)
|
|
if broadcaster != nil {
|
|
broadcaster.Publish(opID, []byte(fmt.Sprintf("Running script '%s' for %s...\n", scriptName, appName)))
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command("/bin/bash", scriptPath)
|
|
cmd.Dir = appDir
|
|
cmd.Env = env
|
|
|
|
if outputWriter != nil {
|
|
cmd.Stdout = outputWriter
|
|
cmd.Stderr = outputWriter
|
|
err := cmd.Run()
|
|
if broadcaster != nil {
|
|
outputWriter.Flush()
|
|
broadcaster.Close(opID)
|
|
}
|
|
return err
|
|
}
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("script failed: %w\nOutput: %s", err, string(output))
|
|
}
|
|
return nil
|
|
}
|