ENVMGR: Environment & Inventory Manager

Purpose: Model environments, targets, agents, and their relationships.

Modules

Module: environment-manager

AspectSpecification
ResponsibilityEnvironment CRUD, ordering, configuration, freeze windows
Dependenciesauthority
Data EntitiesEnvironment, EnvironmentConfig, FreezeWindow
Events Producedenvironment.created, environment.updated, environment.freeze_started, environment.freeze_ended

Key Operations:

CreateEnvironment(name, displayName, orderIndex, config) → Environment
UpdateEnvironment(id, config) → Environment
DeleteEnvironment(id) → void
SetFreezeWindow(environmentId, start, end, reason, exceptions) → FreezeWindow
ClearFreezeWindow(environmentId, windowId) → void
ListEnvironments(tenantId) → Environment[]
GetEnvironmentState(id) → EnvironmentState

Environment Entity:

interface Environment {
  id: UUID;
  tenantId: UUID;
  name: string;                    // "dev", "stage", "prod"
  displayName: string;             // "Development"
  orderIndex: number;              // 0, 1, 2 for promotion order
  config: EnvironmentConfig;
  freezeWindows: FreezeWindow[];
  requiredApprovals: number;       // 0 for dev, 1+ for prod
  requireSeparationOfDuties: boolean;
  autoPromoteFrom: UUID | null;    // auto-promote from this env
  promotionPolicy: string;         // OPA policy name
  createdAt: DateTime;
  updatedAt: DateTime;
}

interface EnvironmentConfig {
  variables: Record<string, string>;        // env-specific variables
  secrets: SecretReference[];               // vault references
  registryOverrides: RegistryOverride[];    // per-env registry
  agentLabels: string[];                    // required agent labels
  deploymentTimeout: number;                // seconds
  healthCheckConfig: HealthCheckConfig;
}

interface FreezeWindow {
  id: UUID;
  start: DateTime;
  end: DateTime;
  reason: string;
  createdBy: UUID;
  exceptions: UUID[];  // users who can override
}

Module: target-registry

AspectSpecification
ResponsibilityDeployment target inventory; capability tracking
Dependenciesenvironment-manager, agent-manager
Data EntitiesTarget, TargetGroup, TargetCapability
Events Producedtarget.created, target.updated, target.deleted, target.health_changed

Infrastructure binding readiness rule: an active registry, vault, or settings-store binding is only configuration, not proof of connectivity. Readiness gates must call the binding connectivity test and fail closed when the connector-backed test is unavailable or returns failure. Binding existence alone must never produce registry_pull_ok, vault_reachable, or consul_reachable pass results.

Runtime connector check contract: when IntegrationHub is available to the environment service, InfrastructureBindingService.TestBindingAsync loads the binding’s integration ID, verifies that the integration exists, is enabled, and has the expected type for the binding role (Registry, Vault, or SettingsStore), then delegates to IntegrationHub’s connector-backed TestConnectionAsync. Missing integrations, disabled integrations, role/type mismatches, connector-test exceptions, or absent IntegrationHub wiring are failed readiness checks, not passes.

Honest-health rule (topology projection): release.targets.health_status is a stored value that is only trustworthy when recently written — a target healthy weeks ago (or a dead host) must NOT keep rendering HEALTHY. The Platform topology projection (TopologyInventoryMapper) time-bounds it against last_health_check freshness and the bound agent’s last_heartbeat_at: a stored healthy with no fresh check projects as unknown (not-evaluated); a healthy whose bound agent’s heartbeat has aged past the TTL projects as offline; non-healthy stored values (degraded/unhealthy/offline) pass through as real negative signals. Agent status is likewise projected offline once its heartbeat ages out and pending when it never reported. Never render a confident HEALTHY/ACTIVE over a value nothing refreshed.

Scheduled evaluation (TopologyReadinessScheduler, RO WebApi, ReleaseOrchestrator:TopologyReadiness): an opt-in tenant-iterating background pass (Enabled=false by default) that (1) refreshes each target’s health_status/last_health_check via the same connection test the “Test connection” button runs — for agent-managed targets a cheap heartbeat-freshness check — and (2) evaluates readiness gates per target so the Environments Command view (/api/v1/release-orchestrator/environments/operational-truth) can populate without a manual validate-all action. The cadence defaults to five minutes with a 60-second minimum, and each target step is bounded by PerTargetTimeout (default 12s). A timeout persists Unreachable using the still-live host token before readiness runs; if that write fails, readiness is skipped rather than publishing stale Healthy truth. connectivity_ok also requires current target health to be Healthy or Degraded for every target type, including non-Docker targets.

Operational boundary: there is no distributed scheduler lease, so enable the pass on exactly one WebApi replica. Multiple enabled replicas duplicate probes and writes. In sealed/air-gap environments keep it disabled unless every configured target/binding destination is locally reachable or explicitly allowed; the scheduler has no inherent public egress, but its operator-configured probes are network activity. Use the authenticated readiness/validate-all endpoint for manual evaluation when periodic mutation is not appropriate. The scheduler is cadence-only; deployment/heartbeat event triggers are not implemented.

Target Types (plugin-provided):

TypeDescription
docker_hostSingle Docker host
compose_hostDocker Compose host
ssh_remoteGeneric SSH target
winrm_remoteWindows remote target
ecs_serviceAWS ECS service
nomad_jobHashiCorp Nomad job

Target Entity:

interface Target {
  id: UUID;
  tenantId: UUID;
  environmentId: UUID;
  name: string;                         // "prod-web-01"
  targetType: string;                   // "docker_host"
  connection: TargetConnection;         // type-specific
  capabilities: TargetCapability[];
  labels: Record<string, string>;       // for grouping
  healthStatus: HealthStatus;
  lastHealthCheck: DateTime;
  deploymentDirectory: string;          // where artifacts are placed
  currentDigest: string | null;         // what's currently deployed
  agentId: UUID | null;                 // assigned agent
}

interface TargetConnection {
  // Common fields
  host: string;
  port: number;

  // Type-specific (examples)
  // docker_host:
  dockerSocket?: string;
  tlsCert?: SecretReference;

  // ssh_remote:
  username?: string;
  privateKey?: SecretReference;

  // ecs_service:
  cluster?: string;
  service?: string;
  region?: string;
  roleArn?: string;
}

interface TargetGroup {
  id: UUID;
  tenantId: UUID;
  environmentId: UUID;
  name: string;
  labels: Record<string, string>;
  createdAt: DateTime;
}

Module: agent-manager

AspectSpecification
ResponsibilityAgent registration, heartbeat, capability advertisement
Dependenciesauthority (for agent tokens)
Data EntitiesAgent, AgentCapability, AgentHeartbeat
Events Producedagent.registered, agent.online, agent.offline, agent.capability_changed

Agent Lifecycle:

  1. Agent starts, requests registration token from Authority
  2. Agent registers with capabilities and labels
  3. Agent sends heartbeats (default: 30s interval)
  4. Agent pulls tasks from task queue
  5. Agent reports task completion/failure

Agent Entity:

interface Agent {
  id: UUID;
  tenantId: UUID;
  name: string;
  version: string;
  capabilities: AgentCapability[];
  labels: Record<string, string>;
  status: "online" | "offline" | "degraded";
  lastHeartbeat: DateTime;
  assignedTargets: UUID[];
  resourceUsage: ResourceUsage;
}

interface AgentCapability {
  type: string;           // "docker", "compose", "ssh", "winrm"
  version: string;        // capability version
  config: object;         // capability-specific config
}

interface ResourceUsage {
  cpuPercent: number;
  memoryPercent: number;
  diskPercent: number;
  activeTasks: number;
}

Agent Registration Protocol:

1. Admin generates registration token (one-time use)
   POST /api/v1/admin/agent-tokens
   → { token: "reg_xxx", expiresAt: "..." }

2. Agent starts with registration token
   ./stella-agent --register --token=reg_xxx

3. Agent requests mTLS certificate
   POST /api/v1/agents/register
   Headers: X-Registration-Token: reg_xxx
   Body: { name, version, capabilities, csr }
   → { agentId, certificate, caCertificate }

4. Agent establishes mTLS connection
   Uses issued certificate for all subsequent requests

5. Agent requests short-lived JWT for task execution
   POST /api/v1/agents/token (over mTLS)
   → { token, expiresIn: 3600 }  // 1 hour

Module: inventory-sync

AspectSpecification
ResponsibilityDrift detection; expected vs actual state reconciliation
Dependenciestarget-registry, agent-manager
Events Producedinventory.drift_detected, inventory.reconciled

Drift Detection Process:

  1. Read stella.version.json from target deployment directory
  2. Compare with expected state in database
  3. Flag discrepancies (digest mismatch, missing sticker, unexpected files)
  4. Report on dashboard

Drift Detection Types:

Drift TypeDescriptionSeverity
digest_mismatchRunning digest differs from expectedCritical
missing_stickerNo version sticker found on targetWarning
stale_stickerSticker timestamp older than last deploymentWarning
orphan_containerContainer not managed by StellaInfo
extra_filesUnexpected files in deployment directoryInfo

Cache Eviction Policies

Environment configurations and target states are cached to improve performance. All caches MUST have bounded size and TTL-based eviction:

Cache TypePurposeTTLMax SizeEviction Strategy
Environment ConfigsEnvironment configuration data30 minutes500 entriesSliding expiration
Target HealthTarget health status5 minutes2,000 entriesSliding expiration
Agent CapabilitiesAgent capability advertisement10 minutes1,000 entriesSliding expiration
Freeze WindowsActive freeze window checks15 minutes100 entriesAbsolute expiration

Implementation:

public class EnvironmentConfigCache
{
    private readonly MemoryCache _cache;

    public EnvironmentConfigCache()
    {
        _cache = new MemoryCache(new MemoryCacheOptions
        {
            SizeLimit = 500  // Max 500 environment configs
        });
    }

    public void CacheConfig(Guid environmentId, EnvironmentConfig config)
    {
        _cache.Set(environmentId, config, new MemoryCacheEntryOptions
        {
            Size = 1,
            SlidingExpiration = TimeSpan.FromMinutes(30)  // 30-minute TTL
        });
    }

    public EnvironmentConfig? GetCachedConfig(Guid environmentId)
        => _cache.Get<EnvironmentConfig>(environmentId);

    public void InvalidateConfig(Guid environmentId)
        => _cache.Remove(environmentId);
}

Cache Invalidation:

Reference: See Implementation Guide for cache implementation patterns.


Database Schema

-- Environments
CREATE TABLE release.environments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    name VARCHAR(100) NOT NULL,
    display_name VARCHAR(255) NOT NULL,
    order_index INTEGER NOT NULL,
    config JSONB NOT NULL DEFAULT '{}',
    freeze_windows JSONB NOT NULL DEFAULT '[]',
    required_approvals INTEGER NOT NULL DEFAULT 0,
    require_sod BOOLEAN NOT NULL DEFAULT FALSE,
    auto_promote_from UUID REFERENCES release.environments(id),
    promotion_policy VARCHAR(255),
    deployment_timeout INTEGER NOT NULL DEFAULT 600,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (tenant_id, name)
);

CREATE INDEX idx_environments_tenant ON release.environments(tenant_id);
CREATE INDEX idx_environments_order ON release.environments(tenant_id, order_index);

-- Target Groups
CREATE TABLE release.target_groups (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    environment_id UUID NOT NULL REFERENCES release.environments(id) ON DELETE CASCADE,
    name VARCHAR(255) NOT NULL,
    labels JSONB NOT NULL DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (tenant_id, environment_id, name)
);

-- Targets
CREATE TABLE release.targets (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    environment_id UUID NOT NULL REFERENCES release.environments(id) ON DELETE CASCADE,
    target_group_id UUID REFERENCES release.target_groups(id),
    name VARCHAR(255) NOT NULL,
    target_type VARCHAR(100) NOT NULL,
    connection JSONB NOT NULL,
    capabilities JSONB NOT NULL DEFAULT '[]',
    labels JSONB NOT NULL DEFAULT '{}',
    deployment_directory VARCHAR(500),
    health_status VARCHAR(50) NOT NULL DEFAULT 'unknown',
    last_health_check TIMESTAMPTZ,
    current_digest VARCHAR(100),
    agent_id UUID REFERENCES release.agents(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (tenant_id, environment_id, name)
);

CREATE INDEX idx_targets_tenant_env ON release.targets(tenant_id, environment_id);
CREATE INDEX idx_targets_type ON release.targets(target_type);
CREATE INDEX idx_targets_labels ON release.targets USING GIN (labels);

-- Agents
CREATE TABLE release.agents (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    name VARCHAR(255) NOT NULL,
    version VARCHAR(50) NOT NULL,
    capabilities JSONB NOT NULL DEFAULT '[]',
    labels JSONB NOT NULL DEFAULT '{}',
    status VARCHAR(50) NOT NULL DEFAULT 'offline',
    last_heartbeat TIMESTAMPTZ,
    resource_usage JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (tenant_id, name)
);

CREATE INDEX idx_agents_tenant ON release.agents(tenant_id);
CREATE INDEX idx_agents_status ON release.agents(status);
CREATE INDEX idx_agents_capabilities ON release.agents USING GIN (capabilities);

API Endpoints

# Environments
POST   /api/v1/environments
GET    /api/v1/environments
GET    /api/v1/environments/{id}
PUT    /api/v1/environments/{id}
DELETE /api/v1/environments/{id}

# Freeze Windows
POST   /api/v1/environments/{envId}/freeze-windows
GET    /api/v1/environments/{envId}/freeze-windows
DELETE /api/v1/environments/{envId}/freeze-windows/{windowId}

# Target Groups
POST   /api/v1/environments/{envId}/target-groups
GET    /api/v1/environments/{envId}/target-groups
GET    /api/v1/target-groups/{id}
PUT    /api/v1/target-groups/{id}
DELETE /api/v1/target-groups/{id}

# Targets
POST   /api/v1/targets
GET    /api/v1/targets
GET    /api/v1/targets/{id}
PUT    /api/v1/targets/{id}
DELETE /api/v1/targets/{id}
POST   /api/v1/targets/{id}/health-check
GET    /api/v1/targets/{id}/sticker
GET    /api/v1/targets/{id}/drift

# Agents
POST   /api/v1/agents/register
GET    /api/v1/agents
GET    /api/v1/agents/{id}
PUT    /api/v1/agents/{id}
DELETE /api/v1/agents/{id}
POST   /api/v1/agents/{id}/heartbeat
POST   /api/v1/agents/{id}/tasks/{taskId}/complete

References