mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Provider-hosted MSP client workspaces previously sat at Community tier forever: the runtime refreshed leases against the built-in Pulse Cloud URL (hibernated, 522) instead of the provider control plane, and release-build images verify leases only against the embedded Pulse key, which an operator-generated CP_TRIAL_ACTIVATION_PRIVATE_KEY can never satisfy. - Inject PULSE_PRO_TRIAL_SIGNUP_URL=CP_BASE_URL into client containers so lease refresh targets the provider control plane. - Chain trust through the Pulse-signed provider MSP license: the license binds the provider's lease signing public key (entitlement_signing_public_key claim); the control plane embeds the license in every lease (provider_license claim); release-build runtimes verify embedded Pulse root -> provider license -> lease signature. - Cap chain-verified leases at ProviderChainedLeaseCapabilities: MSP tier plus white_label (branded per-client reports), minus Pulse-service-backed relay/mobile_app/push_notifications, which otherwise loop doomed registrations against Pulse's relay. - Fail fast at control-plane startup when the license does not bind the configured signing key, instead of provisioning silently unlicensed client workspaces. Verified live on a Colima harness: release-tagged tenant image with test embedded root, Traefik TLS, full provider-msp proof, tenant reports valid=true plan_version=msp_growth with white_label and zero relay failures.
1072 lines
32 KiB
Go
1072 lines
32 KiB
Go
package docker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/containerd/errdefs"
|
|
"github.com/moby/moby/api/types/container"
|
|
"github.com/moby/moby/api/types/mount"
|
|
"github.com/moby/moby/api/types/network"
|
|
"github.com/moby/moby/client"
|
|
"github.com/rs/zerolog/log"
|
|
|
|
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
|
|
)
|
|
|
|
// ManagerConfig holds Docker manager settings.
|
|
type ManagerConfig struct {
|
|
Image string
|
|
Network string
|
|
IsolateTenantNetworks bool
|
|
TenantNetworkPrefix string
|
|
SupportContainerLabels []string
|
|
BaseDomain string
|
|
TrialActivationPublicKey string
|
|
// ControlPlaneBaseURL is the control plane's own external base URL
|
|
// (CP_BASE_URL). It is injected into client runtimes as the hosted
|
|
// commercial base URL so entitlement lease refresh targets this
|
|
// control plane instead of the built-in Pulse Cloud default.
|
|
ControlPlaneBaseURL string
|
|
TrustedProxyCIDRs []string
|
|
// TenantDisplayName resolves a tenant's workspace display name at
|
|
// container-create time; the value is injected as PULSE_TENANT_NAME so
|
|
// alert webhook payloads carry a human-readable workspace label instead
|
|
// of falling back to the tenant ID. Display-name changes after creation
|
|
// apply on the next runtime rollout, which recreates the container with
|
|
// freshly resolved env. May be nil.
|
|
TenantDisplayName func(tenantID string) string
|
|
TenantReportBrand TenantReportBrandConfig
|
|
TenantRuntimeUID int
|
|
TenantRuntimeGID int
|
|
MemoryLimit int64 // bytes
|
|
CPUShares int64
|
|
TenantLogMaxSize string
|
|
TenantLogMaxFile int
|
|
ContainerPort int // port inside the container (default 7655)
|
|
}
|
|
|
|
type TenantReportBrandConfig struct {
|
|
DisplayName string
|
|
LogoPath string
|
|
LogoBase64 string
|
|
LogoFormat string
|
|
}
|
|
|
|
// Manager orchestrates Docker containers for tenant lifecycle.
|
|
type Manager struct {
|
|
cli *client.Client
|
|
cfg ManagerConfig
|
|
}
|
|
|
|
// RuntimeContainerInfo is the canonical live-container snapshot used by
|
|
// operator rollout and reconciliation flows.
|
|
type RuntimeContainerInfo struct {
|
|
ID string
|
|
Name string
|
|
ImageRef string
|
|
ImageID string
|
|
Running bool
|
|
RouteHost string
|
|
PublicURL string
|
|
}
|
|
|
|
type DiskUsageClass struct {
|
|
ActiveCount int64
|
|
TotalCount int64
|
|
Reclaimable int64
|
|
TotalSize int64
|
|
}
|
|
|
|
type DiskUsageSnapshot struct {
|
|
Images DiskUsageClass
|
|
Containers DiskUsageClass
|
|
BuildCache DiskUsageClass
|
|
Volumes DiskUsageClass
|
|
}
|
|
|
|
type RuntimeContainerSummary struct {
|
|
ID string
|
|
Name string
|
|
Image string
|
|
ImageID string
|
|
State string
|
|
Status string
|
|
HealthStatus string
|
|
Created int64
|
|
}
|
|
|
|
type RuntimePrerequisiteOptions struct {
|
|
PullImage bool
|
|
}
|
|
|
|
type RuntimePrerequisiteReport struct {
|
|
OK bool
|
|
DockerReachable bool
|
|
NetworkName string
|
|
NetworkID string
|
|
NetworkOK bool
|
|
ImageRef string
|
|
ImageID string
|
|
ImageAvailable bool
|
|
ImagePulled bool
|
|
ImagePullRequired bool
|
|
Failures []string
|
|
}
|
|
|
|
const immutableOwnershipPathsEnv = "PULSE_IMMUTABLE_OWNERSHIP_PATHS"
|
|
|
|
const (
|
|
tenantRuntimeUID = 1000
|
|
tenantRuntimeGID = 1000
|
|
defaultTenantLogMaxSize = "10m"
|
|
defaultTenantLogMaxFile = 3
|
|
)
|
|
|
|
const (
|
|
providerSupportTraefikLabel = "pulse.provider-msp.role=traefik"
|
|
providerSupportControlPlaneLabel = "pulse.provider-msp.role=control-plane"
|
|
tenantRuntimeNetworkLabel = "pulse.provider-msp.network"
|
|
tenantRuntimeNetworkLabelValue = "tenant"
|
|
)
|
|
|
|
// NewManager creates a Docker manager connected to the local daemon.
|
|
func NewManager(cfg ManagerConfig) (*Manager, error) {
|
|
cli, err := client.New(client.FromEnv)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create docker client: %w", err)
|
|
}
|
|
if cfg.ContainerPort == 0 {
|
|
cfg.ContainerPort = 7655
|
|
}
|
|
cfg.Network = strings.TrimSpace(cfg.Network)
|
|
cfg.TenantNetworkPrefix = strings.TrimSpace(cfg.TenantNetworkPrefix)
|
|
if cfg.IsolateTenantNetworks {
|
|
if cfg.TenantNetworkPrefix == "" {
|
|
if cfg.Network != "" {
|
|
cfg.TenantNetworkPrefix = cfg.Network + "-tenant"
|
|
} else {
|
|
cfg.TenantNetworkPrefix = "pulse-tenant"
|
|
}
|
|
}
|
|
if len(cfg.SupportContainerLabels) == 0 {
|
|
cfg.SupportContainerLabels = []string{
|
|
providerSupportTraefikLabel,
|
|
providerSupportControlPlaneLabel,
|
|
}
|
|
}
|
|
}
|
|
return &Manager{cli: cli, cfg: cfg}, nil
|
|
}
|
|
|
|
// Close closes the Docker client.
|
|
func (m *Manager) Close() error {
|
|
if m.cli != nil {
|
|
return m.cli.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DesiredRuntimeRouting returns the canonical hosted route/public URL contract
|
|
// for a tenant on this manager's configured base domain.
|
|
func (m *Manager) DesiredRuntimeRouting(tenantID string) TenantRuntimeRoutingContract {
|
|
if m == nil {
|
|
return TenantRuntimeRoutingContract{}
|
|
}
|
|
return CanonicalTenantRuntimeRouting(tenantID, m.cfg.BaseDomain)
|
|
}
|
|
|
|
func (m *Manager) DiskUsage(ctx context.Context) (*DiskUsageSnapshot, error) {
|
|
if err := m.ensureDaemonReachable(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
usage, err := m.cli.DiskUsage(ctx, client.DiskUsageOptions{
|
|
Containers: true,
|
|
Images: true,
|
|
BuildCache: true,
|
|
Volumes: true,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read docker disk usage: %w", err)
|
|
}
|
|
return &DiskUsageSnapshot{
|
|
Images: DiskUsageClass{
|
|
ActiveCount: usage.Images.ActiveCount,
|
|
TotalCount: usage.Images.TotalCount,
|
|
Reclaimable: usage.Images.Reclaimable,
|
|
TotalSize: usage.Images.TotalSize,
|
|
},
|
|
Containers: DiskUsageClass{
|
|
ActiveCount: usage.Containers.ActiveCount,
|
|
TotalCount: usage.Containers.TotalCount,
|
|
Reclaimable: usage.Containers.Reclaimable,
|
|
TotalSize: usage.Containers.TotalSize,
|
|
},
|
|
BuildCache: DiskUsageClass{
|
|
ActiveCount: usage.BuildCache.ActiveCount,
|
|
TotalCount: usage.BuildCache.TotalCount,
|
|
Reclaimable: usage.BuildCache.Reclaimable,
|
|
TotalSize: usage.BuildCache.TotalSize,
|
|
},
|
|
Volumes: DiskUsageClass{
|
|
ActiveCount: usage.Volumes.ActiveCount,
|
|
TotalCount: usage.Volumes.TotalCount,
|
|
Reclaimable: usage.Volumes.Reclaimable,
|
|
TotalSize: usage.Volumes.TotalSize,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (m *Manager) ListManagedRuntimeContainers(ctx context.Context) ([]RuntimeContainerSummary, error) {
|
|
if err := m.ensureDaemonReachable(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
filters := client.Filters{}
|
|
filters = filters.Add("label", "pulse.managed=true")
|
|
result, err := m.cli.ContainerList(ctx, client.ContainerListOptions{
|
|
All: true,
|
|
Filters: filters,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list managed tenant containers: %w", err)
|
|
}
|
|
containers := make([]RuntimeContainerSummary, 0, len(result.Items))
|
|
for _, item := range result.Items {
|
|
name := ""
|
|
if len(item.Names) > 0 {
|
|
name = strings.TrimPrefix(strings.TrimSpace(item.Names[0]), "/")
|
|
}
|
|
healthStatus := string(container.NoHealthcheck)
|
|
if item.Health != nil {
|
|
healthStatus = string(item.Health.Status)
|
|
}
|
|
containers = append(containers, RuntimeContainerSummary{
|
|
ID: item.ID,
|
|
Name: name,
|
|
Image: item.Image,
|
|
ImageID: item.ImageID,
|
|
State: string(item.State),
|
|
Status: item.Status,
|
|
HealthStatus: healthStatus,
|
|
Created: item.Created,
|
|
})
|
|
}
|
|
return containers, nil
|
|
}
|
|
|
|
func (m *Manager) CheckRuntimePrerequisites(ctx context.Context, opts RuntimePrerequisiteOptions) (*RuntimePrerequisiteReport, error) {
|
|
if m == nil {
|
|
return &RuntimePrerequisiteReport{
|
|
OK: false,
|
|
Failures: []string{"docker manager is not initialized"},
|
|
}, nil
|
|
}
|
|
report := &RuntimePrerequisiteReport{
|
|
OK: true,
|
|
NetworkName: strings.TrimSpace(m.cfg.Network),
|
|
ImageRef: strings.TrimSpace(m.cfg.Image),
|
|
}
|
|
addFailure := func(format string, args ...any) {
|
|
report.OK = false
|
|
report.Failures = append(report.Failures, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
if err := m.ensureDaemonReachable(ctx); err != nil {
|
|
addFailure("%v", err)
|
|
return report, nil
|
|
}
|
|
report.DockerReachable = true
|
|
|
|
if report.NetworkName == "" {
|
|
addFailure("tenant Docker network is required")
|
|
} else {
|
|
inspect, err := m.cli.NetworkInspect(ctx, report.NetworkName, client.NetworkInspectOptions{})
|
|
if err != nil {
|
|
addFailure("inspect tenant Docker network %q: %v", report.NetworkName, err)
|
|
} else {
|
|
report.NetworkOK = true
|
|
report.NetworkID = strings.TrimSpace(inspect.Network.ID)
|
|
}
|
|
}
|
|
|
|
image, pulled, err := m.ensureRuntimeImageAvailable(ctx, opts.PullImage)
|
|
report.ImagePulled = pulled
|
|
report.ImageID = image
|
|
if err != nil {
|
|
if !opts.PullImage && errdefs.IsNotFound(err) {
|
|
report.ImagePullRequired = true
|
|
addFailure("tenant runtime image %q is not present locally; rerun preflight with image pulling enabled or pull it before creating a workspace", report.ImageRef)
|
|
} else {
|
|
addFailure("%v", err)
|
|
}
|
|
} else {
|
|
report.ImageAvailable = true
|
|
}
|
|
|
|
return report, nil
|
|
}
|
|
|
|
func (m *Manager) tenantNetworkName(tenantID string) string {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
prefix := strings.TrimSpace(m.cfg.TenantNetworkPrefix)
|
|
if prefix == "" {
|
|
prefix = strings.TrimSpace(m.cfg.Network)
|
|
}
|
|
if prefix == "" {
|
|
prefix = "pulse-tenant"
|
|
}
|
|
return sanitizeDockerNameFragment(prefix + "-" + strings.TrimSpace(tenantID))
|
|
}
|
|
|
|
func sanitizeDockerNameFragment(raw string) string {
|
|
raw = strings.ToLower(strings.TrimSpace(raw))
|
|
if raw == "" {
|
|
return "pulse-tenant"
|
|
}
|
|
var b strings.Builder
|
|
lastDash := false
|
|
for _, r := range raw {
|
|
allowed := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '.' || r == '-'
|
|
if !allowed {
|
|
if !lastDash {
|
|
b.WriteByte('-')
|
|
lastDash = true
|
|
}
|
|
continue
|
|
}
|
|
b.WriteRune(r)
|
|
lastDash = r == '-'
|
|
}
|
|
out := strings.Trim(b.String(), "-_.")
|
|
if out == "" {
|
|
return "pulse-tenant"
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m *Manager) ensureTenantNetwork(ctx context.Context, tenantID string) (string, error) {
|
|
if m == nil {
|
|
return "", fmt.Errorf("docker manager unavailable")
|
|
}
|
|
if !m.cfg.IsolateTenantNetworks {
|
|
networkName := strings.TrimSpace(m.cfg.Network)
|
|
if networkName == "" {
|
|
return "", fmt.Errorf("tenant Docker network is required")
|
|
}
|
|
return networkName, nil
|
|
}
|
|
|
|
networkName := m.tenantNetworkName(tenantID)
|
|
inspect, err := m.cli.NetworkInspect(ctx, networkName, client.NetworkInspectOptions{})
|
|
if err == nil {
|
|
if got := strings.TrimSpace(inspect.Network.Labels["pulse.tenant.id"]); got != "" && got != tenantID {
|
|
return "", fmt.Errorf("tenant network %q belongs to tenant %q, not %q", networkName, got, tenantID)
|
|
}
|
|
return networkName, nil
|
|
}
|
|
if !errdefs.IsNotFound(err) {
|
|
return "", fmt.Errorf("inspect tenant network %q: %w", networkName, err)
|
|
}
|
|
|
|
_, err = m.cli.NetworkCreate(ctx, networkName, client.NetworkCreateOptions{
|
|
Driver: "bridge",
|
|
Labels: map[string]string{
|
|
"pulse.managed": "true",
|
|
"pulse.tenant.id": tenantID,
|
|
tenantRuntimeNetworkLabel: tenantRuntimeNetworkLabelValue,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("create tenant network %q: %w", networkName, err)
|
|
}
|
|
return networkName, nil
|
|
}
|
|
|
|
func (m *Manager) connectSupportContainersToTenantNetwork(ctx context.Context, networkName string) error {
|
|
if m == nil || !m.cfg.IsolateTenantNetworks {
|
|
return nil
|
|
}
|
|
for _, label := range m.cfg.SupportContainerLabels {
|
|
label = strings.TrimSpace(label)
|
|
if label == "" {
|
|
continue
|
|
}
|
|
if err := m.connectSupportContainersByLabel(ctx, networkName, label); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) connectSupportContainersByLabel(ctx context.Context, networkName, label string) error {
|
|
filters := client.Filters{}
|
|
filters = filters.Add("label", label)
|
|
result, err := m.cli.ContainerList(ctx, client.ContainerListOptions{
|
|
Filters: filters,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("list provider support containers for label %q: %w", label, err)
|
|
}
|
|
if len(result.Items) == 0 {
|
|
return fmt.Errorf("provider support container with label %q is required for isolated tenant network %q", label, networkName)
|
|
}
|
|
|
|
for _, item := range result.Items {
|
|
if item.ID == "" {
|
|
continue
|
|
}
|
|
if err := m.ensureContainerConnectedToNetwork(ctx, networkName, item.ID); err != nil {
|
|
return fmt.Errorf("connect provider support container %s to tenant network %q: %w", item.ID[:min(len(item.ID), 12)], networkName, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) ensureContainerConnectedToNetwork(ctx context.Context, networkName, containerID string) error {
|
|
inspect, err := m.cli.NetworkInspect(ctx, networkName, client.NetworkInspectOptions{})
|
|
if err != nil {
|
|
return fmt.Errorf("inspect tenant network %q: %w", networkName, err)
|
|
}
|
|
for id := range inspect.Network.Containers {
|
|
if id == containerID || strings.HasPrefix(id, containerID) || strings.HasPrefix(containerID, id) {
|
|
return nil
|
|
}
|
|
}
|
|
_, err = m.cli.NetworkConnect(ctx, networkName, client.NetworkConnectOptions{
|
|
Container: containerID,
|
|
EndpointConfig: &network.EndpointSettings{},
|
|
})
|
|
return err
|
|
}
|
|
|
|
// IsNotFound reports whether Docker treated an identifier as missing.
|
|
func IsNotFound(err error) bool {
|
|
return errdefs.IsNotFound(err)
|
|
}
|
|
|
|
func (m *Manager) ensureDaemonReachable(ctx context.Context) error {
|
|
if m == nil || m.cli == nil {
|
|
return fmt.Errorf("docker client unavailable")
|
|
}
|
|
if _, err := m.cli.Ping(ctx, client.PingOptions{}); err != nil {
|
|
return fmt.Errorf("ping docker daemon: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) ensureRuntimeImageAvailable(ctx context.Context, pullIfMissing bool) (imageID string, pulled bool, err error) {
|
|
if err := m.ensureDaemonReachable(ctx); err != nil {
|
|
return "", false, err
|
|
}
|
|
imageRef := strings.TrimSpace(m.cfg.Image)
|
|
if imageRef == "" {
|
|
return "", false, fmt.Errorf("tenant runtime image is required")
|
|
}
|
|
inspect, err := m.cli.ImageInspect(ctx, imageRef)
|
|
if err == nil {
|
|
return strings.TrimSpace(inspect.ID), false, nil
|
|
}
|
|
if !errdefs.IsNotFound(err) {
|
|
return "", false, fmt.Errorf("inspect tenant runtime image %q: %w", imageRef, err)
|
|
}
|
|
if !pullIfMissing {
|
|
return "", false, err
|
|
}
|
|
rc, err := m.cli.ImagePull(ctx, imageRef, client.ImagePullOptions{})
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("pull tenant runtime image %q: %w", imageRef, err)
|
|
}
|
|
defer func() {
|
|
if closeErr := rc.Close(); closeErr != nil && err == nil {
|
|
err = fmt.Errorf("close tenant runtime image pull stream %q: %w", imageRef, closeErr)
|
|
}
|
|
}()
|
|
if _, err := io.Copy(io.Discard, rc); err != nil {
|
|
return "", true, fmt.Errorf("read tenant runtime image pull output %q: %w", imageRef, err)
|
|
}
|
|
inspect, err = m.cli.ImageInspect(ctx, imageRef)
|
|
if err != nil {
|
|
return "", true, fmt.Errorf("inspect pulled tenant runtime image %q: %w", imageRef, err)
|
|
}
|
|
return strings.TrimSpace(inspect.ID), true, nil
|
|
}
|
|
|
|
// CreateAndStart creates and starts a tenant container.
|
|
// tenantDataDir is the host path that gets bind-mounted to /etc/pulse in the container.
|
|
func (m *Manager) CreateAndStart(ctx context.Context, tenantID, tenantDataDir string) (containerID string, err error) {
|
|
if err := m.ensureDaemonReachable(ctx); err != nil {
|
|
return "", err
|
|
}
|
|
if _, _, err := m.ensureRuntimeImageAvailable(ctx, true); err != nil {
|
|
return "", fmt.Errorf("prepare tenant runtime image: %w", err)
|
|
}
|
|
if err := prepareTenantRuntimeMountSources(tenantDataDir, tenantRuntimeUIDFor(m.cfg), tenantRuntimeGIDFor(m.cfg)); err != nil {
|
|
return "", fmt.Errorf("prepare tenant runtime mounts for %s: %w", tenantID, err)
|
|
}
|
|
|
|
tenantNetworkName, err := m.ensureTenantNetwork(ctx, tenantID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
labels := TraefikLabels(tenantID, m.cfg.BaseDomain, m.cfg.ContainerPort, tenantNetworkName)
|
|
labels["pulse.managed"] = "true"
|
|
|
|
containerName := "pulse-" + tenantID
|
|
|
|
resp, err := m.cli.ContainerCreate(ctx, client.ContainerCreateOptions{
|
|
Config: tenantRuntimeContainerConfig(tenantID, m.cfg, labels, m.tenantTrustedProxyCIDRs(ctx, tenantNetworkName)),
|
|
HostConfig: tenantRuntimeHostConfig(tenantDataDir, m.cfg),
|
|
NetworkingConfig: &network.NetworkingConfig{
|
|
EndpointsConfig: map[string]*network.EndpointSettings{
|
|
tenantNetworkName: {},
|
|
},
|
|
},
|
|
Name: containerName,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("create container for %s: %w", tenantID, err)
|
|
}
|
|
|
|
if err := m.connectSupportContainersToTenantNetwork(ctx, tenantNetworkName); err != nil {
|
|
_, _ = m.cli.ContainerRemove(ctx, resp.ID, client.ContainerRemoveOptions{Force: true})
|
|
return resp.ID, err
|
|
}
|
|
|
|
if _, err := m.cli.ContainerStart(ctx, resp.ID, client.ContainerStartOptions{}); err != nil {
|
|
return resp.ID, fmt.Errorf("start container for %s: %w", tenantID, err)
|
|
}
|
|
|
|
log.Info().
|
|
Str("tenant_id", tenantID).
|
|
Str("container_id", resp.ID[:12]).
|
|
Str("container_name", containerName).
|
|
Msg("Tenant container started")
|
|
|
|
return resp.ID, nil
|
|
}
|
|
|
|
func tenantRuntimeContainerConfig(tenantID string, cfg ManagerConfig, labels map[string]string, trustedProxyCIDRs []string) *container.Config {
|
|
return &container.Config{
|
|
Image: cfg.Image,
|
|
User: tenantRuntimeUserFor(cfg),
|
|
Labels: labels,
|
|
Env: tenantEnvForRuntime(tenantID, tenantDisplayNameFor(cfg, tenantID), cfg.BaseDomain, cfg.TrialActivationPublicKey, cfg.ControlPlaneBaseURL, trustedProxyCIDRs, tenantRuntimeUIDFor(cfg), tenantRuntimeGIDFor(cfg), cfg.TenantReportBrand),
|
|
}
|
|
}
|
|
|
|
func tenantDisplayNameFor(cfg ManagerConfig, tenantID string) string {
|
|
if cfg.TenantDisplayName == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(cfg.TenantDisplayName(tenantID))
|
|
}
|
|
|
|
func tenantRuntimeUserFor(cfg ManagerConfig) string {
|
|
return fmt.Sprintf("%d:%d", tenantRuntimeUIDFor(cfg), tenantRuntimeGIDFor(cfg))
|
|
}
|
|
|
|
func tenantRuntimeUIDFor(cfg ManagerConfig) int {
|
|
if cfg.TenantRuntimeUID > 0 {
|
|
return cfg.TenantRuntimeUID
|
|
}
|
|
return tenantRuntimeUID
|
|
}
|
|
|
|
func tenantRuntimeGIDFor(cfg ManagerConfig) int {
|
|
if cfg.TenantRuntimeGID > 0 {
|
|
return cfg.TenantRuntimeGID
|
|
}
|
|
return tenantRuntimeGID
|
|
}
|
|
|
|
func tenantRuntimeLogConfig(maxSize string, maxFile int) container.LogConfig {
|
|
maxSize = strings.TrimSpace(maxSize)
|
|
if maxSize == "" {
|
|
maxSize = defaultTenantLogMaxSize
|
|
}
|
|
if maxFile <= 0 {
|
|
maxFile = defaultTenantLogMaxFile
|
|
}
|
|
return container.LogConfig{
|
|
Type: "json-file",
|
|
Config: map[string]string{
|
|
"max-size": maxSize,
|
|
"max-file": fmt.Sprintf("%d", maxFile),
|
|
},
|
|
}
|
|
}
|
|
|
|
func tenantRuntimeHostConfig(tenantDataDir string, cfg ManagerConfig) *container.HostConfig {
|
|
return &container.HostConfig{
|
|
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
|
|
LogConfig: tenantRuntimeLogConfig(cfg.TenantLogMaxSize, cfg.TenantLogMaxFile),
|
|
Resources: container.Resources{
|
|
Memory: cfg.MemoryLimit,
|
|
CPUShares: cfg.CPUShares,
|
|
},
|
|
Mounts: tenantMounts(tenantDataDir),
|
|
SecurityOpt: tenantRuntimeSecurityOptions(),
|
|
CapDrop: []string{"ALL"},
|
|
ReadonlyRootfs: true,
|
|
Tmpfs: map[string]string{
|
|
"/run": "rw,noexec,nosuid,nodev,size=16m",
|
|
"/tmp": "rw,noexec,nosuid,nodev,size=64m",
|
|
},
|
|
}
|
|
}
|
|
|
|
func tenantRuntimeSecurityOptions() []string {
|
|
// Do not set seccomp=unconfined. Docker's default seccomp profile remains
|
|
// active unless the daemon has been explicitly weakened outside Pulse.
|
|
return []string{"no-new-privileges:true"}
|
|
}
|
|
|
|
func tenantImmutableOwnershipPaths() []string {
|
|
return []string{
|
|
"/etc/pulse/secrets/handoff.key",
|
|
"/etc/pulse/.cloud_handoff_key",
|
|
}
|
|
}
|
|
|
|
func tenantRuntimeOwnershipPaths() []string {
|
|
return []string{
|
|
"billing.json",
|
|
filepath.Join("secrets", "handoff.key"),
|
|
".cloud_handoff_key",
|
|
}
|
|
}
|
|
|
|
func tenantEnv(tenantID, tenantName, baseDomain, trialActivationPublicKey, controlPlaneBaseURL string, trustedProxyCIDRs []string, reportBrand TenantReportBrandConfig) []string {
|
|
return tenantEnvForRuntime(tenantID, tenantName, baseDomain, trialActivationPublicKey, controlPlaneBaseURL, trustedProxyCIDRs, tenantRuntimeUID, tenantRuntimeGID, reportBrand)
|
|
}
|
|
|
|
func tenantEnvForRuntime(tenantID, tenantName, baseDomain, trialActivationPublicKey, controlPlaneBaseURL string, trustedProxyCIDRs []string, runtimeUID, runtimeGID int, reportBrand TenantReportBrandConfig) []string {
|
|
routing := CanonicalTenantRuntimeRouting(tenantID, baseDomain)
|
|
tenantID = strings.TrimSpace(tenantID)
|
|
|
|
env := []string{
|
|
"PULSE_DATA_DIR=/etc/pulse",
|
|
"PULSE_HOSTED_MODE=true",
|
|
"PULSE_TENANT_ID=" + tenantID,
|
|
"PULSE_MULTI_TENANT_ENABLED=true",
|
|
fmt.Sprintf("PUID=%d", runtimeUID),
|
|
fmt.Sprintf("PGID=%d", runtimeGID),
|
|
fmt.Sprintf("%s=%s", immutableOwnershipPathsEnv, strings.Join(tenantImmutableOwnershipPaths(), ":")),
|
|
}
|
|
if name := strings.TrimSpace(tenantName); name != "" {
|
|
env = append(env, "PULSE_TENANT_NAME="+name)
|
|
}
|
|
if routing.PublicURL != "" {
|
|
env = append(env, "PULSE_PUBLIC_URL="+routing.PublicURL)
|
|
}
|
|
if strings.TrimSpace(trialActivationPublicKey) != "" {
|
|
env = append(env, "PULSE_TRIAL_ACTIVATION_PUBLIC_KEY="+strings.TrimSpace(trialActivationPublicKey))
|
|
}
|
|
if value := strings.TrimSpace(controlPlaneBaseURL); value != "" {
|
|
// Point hosted entitlement lease refresh at this control plane.
|
|
// Without it the runtime falls back to the built-in Pulse Cloud
|
|
// URL, which a provider-hosted control plane does not operate.
|
|
env = append(env, pkglicensing.ProTrialSignupURLEnvVar+"="+value)
|
|
}
|
|
if len(trustedProxyCIDRs) > 0 {
|
|
env = append(env, "PULSE_TRUSTED_PROXY_CIDRS="+strings.Join(trustedProxyCIDRs, ","))
|
|
}
|
|
if value := strings.TrimSpace(reportBrand.DisplayName); value != "" {
|
|
env = append(env, "PULSE_REPORT_PROVIDER_BRAND_DISPLAY_NAME="+value)
|
|
}
|
|
if value := strings.TrimSpace(reportBrand.LogoPath); value != "" {
|
|
env = append(env, "PULSE_REPORT_PROVIDER_BRAND_LOGO_PATH="+value)
|
|
}
|
|
if value := strings.TrimSpace(reportBrand.LogoBase64); value != "" {
|
|
env = append(env, "PULSE_REPORT_PROVIDER_BRAND_LOGO_BASE64="+value)
|
|
}
|
|
if value := strings.TrimSpace(reportBrand.LogoFormat); value != "" {
|
|
env = append(env, "PULSE_REPORT_PROVIDER_BRAND_LOGO_FORMAT="+value)
|
|
}
|
|
return env
|
|
}
|
|
|
|
func routeHostFromLabels(labels map[string]string) string {
|
|
for key, value := range labels {
|
|
if !strings.HasPrefix(key, "traefik.http.routers.") || !strings.HasSuffix(key, ".rule") {
|
|
continue
|
|
}
|
|
if host := parseTraefikHostRule(value); host != "" {
|
|
return host
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseTraefikHostRule(rule string) string {
|
|
rule = strings.TrimSpace(rule)
|
|
if !strings.HasPrefix(rule, "Host(`") || !strings.HasSuffix(rule, "`)") {
|
|
return ""
|
|
}
|
|
return strings.TrimSuffix(strings.TrimPrefix(rule, "Host(`"), "`)")
|
|
}
|
|
|
|
func envValue(env []string, key string) string {
|
|
prefix := key + "="
|
|
for _, item := range env {
|
|
if !strings.HasPrefix(item, prefix) {
|
|
continue
|
|
}
|
|
return strings.TrimPrefix(item, prefix)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m *Manager) tenantTrustedProxyCIDRs(ctx context.Context, networkNames ...string) []string {
|
|
values := make([]string, 0, len(m.cfg.TrustedProxyCIDRs)+1)
|
|
seen := make(map[string]struct{})
|
|
appendCIDR := func(raw string) {
|
|
canonical := canonicalTrustedProxyCIDR(raw)
|
|
if canonical == "" {
|
|
return
|
|
}
|
|
if _, ok := seen[canonical]; ok {
|
|
return
|
|
}
|
|
seen[canonical] = struct{}{}
|
|
values = append(values, canonical)
|
|
}
|
|
|
|
for _, cidr := range m.cfg.TrustedProxyCIDRs {
|
|
appendCIDR(cidr)
|
|
}
|
|
|
|
if len(networkNames) == 0 && m != nil {
|
|
networkNames = []string{m.cfg.Network}
|
|
}
|
|
for _, networkName := range networkNames {
|
|
networkName = strings.TrimSpace(networkName)
|
|
if m == nil || m.cli == nil || networkName == "" {
|
|
continue
|
|
}
|
|
inspect, err := m.cli.NetworkInspect(ctx, networkName, client.NetworkInspectOptions{})
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("network", networkName).Msg("Failed to inspect tenant network for trusted proxy CIDRs")
|
|
} else {
|
|
for _, cfg := range inspect.Network.IPAM.Config {
|
|
appendCIDR(cfg.Subnet.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.Strings(values)
|
|
return values
|
|
}
|
|
|
|
func canonicalTrustedProxyCIDR(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
if prefix, err := netip.ParsePrefix(raw); err == nil {
|
|
return prefix.Masked().String()
|
|
}
|
|
if addr, err := netip.ParseAddr(raw); err == nil {
|
|
bits := 32
|
|
if addr.Is6() {
|
|
bits = 128
|
|
}
|
|
return netip.PrefixFrom(addr, bits).Masked().String()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func tenantMounts(tenantDataDir string) []mount.Mount {
|
|
return []mount.Mount{
|
|
{
|
|
Type: mount.TypeBind,
|
|
Source: tenantDataDir,
|
|
Target: "/etc/pulse",
|
|
},
|
|
{
|
|
Type: mount.TypeBind,
|
|
Source: filepath.Join(tenantDataDir, "secrets", "handoff.key"),
|
|
Target: "/etc/pulse/secrets/handoff.key",
|
|
ReadOnly: true,
|
|
},
|
|
{
|
|
Type: mount.TypeBind,
|
|
Source: filepath.Join(tenantDataDir, ".cloud_handoff_key"),
|
|
Target: "/etc/pulse/.cloud_handoff_key",
|
|
ReadOnly: true,
|
|
},
|
|
}
|
|
}
|
|
|
|
func prepareTenantRuntimeMountSources(tenantDataDir string, uid, gid int) error {
|
|
if err := filepath.WalkDir(tenantDataDir, func(path string, entry os.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if err := os.Lchown(path, uid, gid); err != nil {
|
|
return fmt.Errorf("chown %s to %d:%d: %w", path, uid, gid, err)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, relPath := range tenantRuntimeOwnershipPaths() {
|
|
path := filepath.Join(tenantDataDir, relPath)
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return fmt.Errorf("stat %s: %w", path, err)
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("tenant runtime mount source %s must not be a symlink", path)
|
|
}
|
|
if err := os.Chmod(path, 0o600); err != nil {
|
|
return fmt.Errorf("chmod %s: %w", path, err)
|
|
}
|
|
if err := os.Chown(path, uid, gid); err != nil {
|
|
return fmt.Errorf("chown %s to %d:%d: %w", path, uid, gid, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Stop stops a tenant container gracefully.
|
|
func (m *Manager) Stop(ctx context.Context, containerID string) error {
|
|
timeout := 30
|
|
_, err := m.cli.ContainerStop(ctx, containerID, client.ContainerStopOptions{Timeout: &timeout})
|
|
return err
|
|
}
|
|
|
|
// Start starts a stopped tenant container.
|
|
func (m *Manager) Start(ctx context.Context, containerID string) error {
|
|
_, err := m.cli.ContainerStart(ctx, containerID, client.ContainerStartOptions{})
|
|
return err
|
|
}
|
|
|
|
// Remove removes a stopped tenant container.
|
|
func (m *Manager) Remove(ctx context.Context, containerID string) error {
|
|
networkNames := m.tenantNetworkNamesForContainer(ctx, containerID)
|
|
_, err := m.cli.ContainerRemove(ctx, containerID, client.ContainerRemoveOptions{
|
|
Force: true,
|
|
})
|
|
if err == nil {
|
|
for _, networkName := range networkNames {
|
|
if removeErr := m.removeTenantNetwork(ctx, networkName); removeErr != nil {
|
|
log.Warn().Err(removeErr).Str("network", networkName).Msg("Failed to remove tenant runtime network")
|
|
}
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (m *Manager) tenantNetworkNamesForContainer(ctx context.Context, containerID string) []string {
|
|
if m == nil || !m.cfg.IsolateTenantNetworks || strings.TrimSpace(containerID) == "" {
|
|
return nil
|
|
}
|
|
inspectResult, err := m.cli.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
inspect := inspectResult.Container
|
|
if inspect.NetworkSettings == nil {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for networkName := range inspect.NetworkSettings.Networks {
|
|
if m.isTenantNetwork(ctx, networkName) {
|
|
out = append(out, networkName)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func (m *Manager) isTenantNetwork(ctx context.Context, networkName string) bool {
|
|
networkName = strings.TrimSpace(networkName)
|
|
if m == nil || networkName == "" {
|
|
return false
|
|
}
|
|
inspect, err := m.cli.NetworkInspect(ctx, networkName, client.NetworkInspectOptions{})
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(inspect.Network.Labels[tenantRuntimeNetworkLabel]) == tenantRuntimeNetworkLabelValue
|
|
}
|
|
|
|
func (m *Manager) removeTenantNetwork(ctx context.Context, networkName string) error {
|
|
if m == nil || strings.TrimSpace(networkName) == "" {
|
|
return nil
|
|
}
|
|
if err := m.disconnectSupportContainersFromTenantNetwork(ctx, networkName); err != nil {
|
|
return err
|
|
}
|
|
if _, err := m.cli.NetworkRemove(ctx, networkName, client.NetworkRemoveOptions{}); err != nil {
|
|
return fmt.Errorf("remove tenant network %q: %w", networkName, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) disconnectSupportContainersFromTenantNetwork(ctx context.Context, networkName string) error {
|
|
if m == nil || !m.cfg.IsolateTenantNetworks {
|
|
return nil
|
|
}
|
|
for _, label := range m.cfg.SupportContainerLabels {
|
|
label = strings.TrimSpace(label)
|
|
if label == "" {
|
|
continue
|
|
}
|
|
filters := client.Filters{}
|
|
filters = filters.Add("label", label)
|
|
result, err := m.cli.ContainerList(ctx, client.ContainerListOptions{
|
|
All: true,
|
|
Filters: filters,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("list provider support containers for disconnect label %q: %w", label, err)
|
|
}
|
|
for _, item := range result.Items {
|
|
if item.ID == "" {
|
|
continue
|
|
}
|
|
_, err := m.cli.NetworkDisconnect(ctx, networkName, client.NetworkDisconnectOptions{
|
|
Container: item.ID,
|
|
Force: true,
|
|
})
|
|
if err != nil && !errdefs.IsNotFound(err) {
|
|
return fmt.Errorf("disconnect provider support container %s from tenant network %q: %w", item.ID[:min(len(item.ID), 12)], networkName, err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Rename renames a tenant container to the supplied Docker name.
|
|
func (m *Manager) Rename(ctx context.Context, containerIDOrName, newName string) error {
|
|
if err := m.ensureDaemonReachable(ctx); err != nil {
|
|
return err
|
|
}
|
|
if _, err := m.cli.ContainerRename(ctx, containerIDOrName, client.ContainerRenameOptions{NewName: newName}); err != nil {
|
|
return fmt.Errorf("rename container %s -> %s: %w", containerIDOrName, newName, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Inspect returns the canonical live metadata for the supplied container
|
|
// identifier.
|
|
func (m *Manager) Inspect(ctx context.Context, containerIDOrName string) (*RuntimeContainerInfo, error) {
|
|
if err := m.ensureDaemonReachable(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
inspectResult, err := m.cli.ContainerInspect(ctx, containerIDOrName, client.ContainerInspectOptions{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inspect container %s: %w", containerIDOrName, err)
|
|
}
|
|
inspect := inspectResult.Container
|
|
imageRef := ""
|
|
if inspect.Config != nil {
|
|
imageRef = strings.TrimSpace(inspect.Config.Image)
|
|
}
|
|
running := inspect.State != nil && inspect.State.Running
|
|
routeHost := ""
|
|
publicURL := ""
|
|
if inspect.Config != nil {
|
|
routeHost = routeHostFromLabels(inspect.Config.Labels)
|
|
publicURL = envValue(inspect.Config.Env, "PULSE_PUBLIC_URL")
|
|
}
|
|
return &RuntimeContainerInfo{
|
|
ID: inspect.ID,
|
|
Name: strings.TrimPrefix(strings.TrimSpace(inspect.Name), "/"),
|
|
ImageRef: imageRef,
|
|
ImageID: strings.TrimSpace(inspect.Image),
|
|
Running: running,
|
|
RouteHost: routeHost,
|
|
PublicURL: publicURL,
|
|
}, nil
|
|
}
|
|
|
|
// StopAndRemove stops then removes a tenant container.
|
|
func (m *Manager) StopAndRemove(ctx context.Context, containerID string) error {
|
|
if err := m.Stop(ctx, containerID); err != nil {
|
|
log.Warn().Err(err).Str("container_id", containerID).Msg("Failed to stop container, forcing remove")
|
|
}
|
|
return m.Remove(ctx, containerID)
|
|
}
|
|
|
|
// HealthCheck performs an HTTP health check against a running container.
|
|
// It connects to the container's published port via the Docker network.
|
|
func (m *Manager) HealthCheck(ctx context.Context, containerID string) (bool, error) {
|
|
inspectResult, err := m.cli.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{})
|
|
if err != nil {
|
|
return false, fmt.Errorf("inspect container: %w", err)
|
|
}
|
|
inspect := inspectResult.Container
|
|
|
|
if !inspect.State.Running {
|
|
return false, nil
|
|
}
|
|
|
|
networkNames := m.healthCheckNetworkCandidates(inspect)
|
|
var netSettings *network.EndpointSettings
|
|
for _, networkName := range networkNames {
|
|
candidate, ok := inspect.NetworkSettings.Networks[networkName]
|
|
if ok && candidate != nil && candidate.IPAddress.IsValid() {
|
|
netSettings = candidate
|
|
break
|
|
}
|
|
}
|
|
if netSettings == nil {
|
|
return false, fmt.Errorf("container not connected to tenant health network candidates %s", strings.Join(networkNames, ", "))
|
|
}
|
|
|
|
healthURL := fmt.Sprintf("http://%s:%d/api/health", netSettings.IPAddress.String(), m.cfg.ContainerPort)
|
|
httpClient := &http.Client{Timeout: 5 * time.Second}
|
|
resp, err := httpClient.Get(healthURL)
|
|
if err != nil {
|
|
return false, nil // unreachable, not an error condition
|
|
}
|
|
defer func() { _, _ = io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
|
|
|
|
return resp.StatusCode == http.StatusOK, nil
|
|
}
|
|
|
|
func (m *Manager) healthCheckNetworkCandidates(inspect container.InspectResponse) []string {
|
|
var names []string
|
|
seen := make(map[string]struct{})
|
|
appendName := func(name string) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return
|
|
}
|
|
if _, ok := seen[name]; ok {
|
|
return
|
|
}
|
|
seen[name] = struct{}{}
|
|
names = append(names, name)
|
|
}
|
|
|
|
if m != nil && m.cfg.IsolateTenantNetworks && inspect.Config != nil {
|
|
if tenantID := strings.TrimSpace(inspect.Config.Labels["pulse.tenant.id"]); tenantID != "" {
|
|
appendName(m.tenantNetworkName(tenantID))
|
|
}
|
|
}
|
|
if m != nil {
|
|
appendName(m.cfg.Network)
|
|
}
|
|
if inspect.NetworkSettings != nil {
|
|
for name := range inspect.NetworkSettings.Networks {
|
|
appendName(name)
|
|
}
|
|
}
|
|
return names
|
|
}
|