-
Notifications
You must be signed in to change notification settings - Fork 0
Reference Multi Environment
Version: 0.4.0 Status: Planning
This specification defines the multi-environment feature for ReadyStackGo v0.4. Organizations can manage multiple isolated environments (e.g., Development, Testing, Production), each with independent configurations, Docker hosts, and deployed stacks.
Key Principles:
- Minimal Variant: Single Docker host per environment (multi-node support deferred to v2.0+)
- Docker Host Uniqueness: Each Docker host URL can only be used by one environment (prevents conflicts)
- Environment Isolation: Each environment has separate connection strings, Docker host, and deployed containers
- Domain-Driven Design: Organization and Environment are core domain aggregates with proper validation
- User Experience: Simple environment switching in UI with context-aware dashboard
- Backward Compatibility: v0.3 single-environment configurations automatically migrate to default environment
| Term | Definition |
|---|---|
| Organization | A tenant entity defined in wizard Step 2 (organizationId, organizationName) - Aggregate Root |
| Environment | An isolated deployment context within an organization (e.g., "dev", "test", "prod") - Entity within Organization aggregate |
| Active Environment | The currently selected environment in the UI, stored in user session |
| Default Environment | The first environment created during wizard, typically "production" |
| Docker Host | A single Docker daemon endpoint (e.g., tcp://192.168.1.10:2375 or unix:///var/run/docker.sock) - Must be unique across all environments |
Actor: Admin User Preconditions: Wizard completed (v0.3), logged in Flow:
- Admin navigates to Settings → Environments
- Clicks "Add Environment"
- Enters environment details (ID, name, Docker host URL)
- Configures connection strings (Transport, Persistence, EventStore)
- System validates and saves environment configuration
- New environment appears in environment selector
Actor: Admin User Preconditions: Multiple environments exist Flow:
- Admin clicks environment selector dropdown in header
- Selects different environment (e.g., "test" → "production")
- UI updates active environment context
- Dashboard, containers, stacks refresh to show selected environment's data
- All subsequent operations affect only the active environment
Actor: Admin User Preconditions: Active environment selected Flow:
- Admin clicks "Deploy Stack" in dashboard
- Selects manifest version
- System deploys containers to active environment's Docker host
- Containers use active environment's connection strings
- Deployment status shows in active environment's dashboard
Actor: System Preconditions: Existing v0.3 installation with rsgo.contexts.json Flow:
- User upgrades to v0.4
- On first startup, system detects v0.3 configuration
- System creates default environment ("production")
- Migrates existing rsgo.contexts.json → rsgo.contexts.production.json
- Updates rsgo.system.json with environments array
- Logs migration success
v0.3 Schema:
{
"organizationId": "my-company",
"organizationName": "My Company Ltd.",
"wizardState": "Installed",
"installedVersion": "v0.3.0",
"createdAt": "2025-01-19T10:30:00Z",
"updatedAt": "2025-01-19T10:35:00Z"
}v0.4 Schema (Extended with Type Discriminator):
{
"organizationId": "my-company",
"organizationName": "My Company Ltd.",
"wizardState": "Installed",
"installedVersion": "v0.4.0",
"createdAt": "2025-01-19T10:30:00Z",
"updatedAt": "2025-01-20T14:20:00Z",
"environments": [
{
"$type": "docker-socket",
"id": "production",
"name": "Production",
"socketPath": "unix:///var/run/docker.sock",
"isDefault": true,
"createdAt": "2025-01-19T10:35:00Z"
},
{
"$type": "docker-socket",
"id": "test",
"name": "Test Environment",
"socketPath": "tcp://192.168.1.20:2375",
"isDefault": false,
"createdAt": "2025-01-20T14:20:00Z"
}
]
}Schema Definition:
interface SystemConfig {
organizationId: string;
organizationName: string;
wizardState: WizardState;
installedVersion: string;
createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601
environments: Environment[]; // NEW in v0.4 - Polymorphic array
}
// Base interface for all environment types
interface Environment {
$type: string; // Type discriminator: "docker-socket", "docker-api", "docker-agent"
id: string; // Lowercase alphanumeric + hyphens (e.g., "production", "dev-env")
name: string; // Display name (e.g., "Production", "Development")
isDefault: boolean; // True for the default environment
createdAt: string; // ISO 8601
}
// Docker Socket Environment (v0.4 - ONLY THIS TYPE)
interface DockerSocketEnvironment extends Environment {
$type: "docker-socket";
socketPath: string; // e.g., "unix:///var/run/docker.sock" or "npipe://./pipe/docker_engine"
}
// Future types (v0.5+)
interface DockerApiEnvironment extends Environment {
$type: "docker-api";
apiUrl: string; // e.g., "tcp://192.168.1.10:2375"
useTls: boolean;
tlsCertPath?: string;
tlsKeyPath?: string;
}
interface DockerAgentEnvironment extends Environment {
$type: "docker-agent";
agentUrl: string; // e.g., "tcp://192.168.1.20:9001"
agentSecret: string;
}Validation Rules:
- At least one environment must exist after wizard completion
- Exactly one environment must have
isDefault: true - Environment IDs must be unique within organization
- Environment ID format:
/^[a-z0-9-]+$/(lowercase, numbers, hyphens only) -
Connection strings must be unique across all environments (enforced via polymorphic
GetConnectionString()method) -
$typefield is required for JSON deserialization (type discriminator pattern)
v0.3 Structure (OLD):
/app/config/
rsgo.system.json ← Organization + wizard state
rsgo.security.json ← Admin credentials
rsgo.tls.json ← TLS certificates
rsgo.contexts.json ← Global connection strings (REMOVED in v0.4)
v0.4 Structure (NEW):
/app/config/
rsgo.system.json ← Organization + environments
rsgo.security.json ← Admin credentials
rsgo.tls.json ← TLS certificates
deployments/
production/
readystack-core.deployment.json ← Stack deployment config
monitoring-stack.deployment.json
test/
readystack-core.deployment.json
Key Change: Connection strings moved from global rsgo.contexts.json to per-deployment configuration files.
- Create Admin Account
- Set Organization
- Configure Connections (Simple mode) ← WILL BE REMOVED
- Complete Setup
New Simplified Wizard:
-
Create Admin Account
- Username
- Password (BCrypt hashed)
-
Set Organization
- Organization ID (e.g., "acme-corp")
- Organization Name (e.g., "Acme Corporation")
-
Complete Setup
- Wizard finished
- User redirected to dashboard
- Can create environments via Settings UI
Key Changes:
- ✅ Removed: "Configure Connections" step (now stack-specific, not global)
- ✅ Removed: Mandatory environment creation during wizard
- ✅ Simplified: From 4 steps to 3 steps
- ✅ Flexible: Users can create environments when needed, not forced during setup
Why This Approach:
- Separation of Concerns: Connection strings belong to stack deployments, not wizard setup
- Flexibility: Not all users need environments immediately (e.g., testing, demo scenarios)
- Simplicity: Faster onboarding, less overwhelming for new users
- Alignment with Domain Model: Organization can exist without environments
// v0.3
public enum WizardState
{
NotStarted,
AdminCreated,
OrganizationSet,
ConnectionsSet, // ← REMOVED in v0.4
Installed
}
// v0.4 (Simplified)
public enum WizardState
{
NotStarted,
AdminCreated,
OrganizationSet,
Installed // Direct transition after OrganizationSet
}Breaking Change: The ConnectionsSet state is removed. Existing v0.3 installations with ConnectionsSet will be automatically migrated to Installed during upgrade.
Get All Environments
GET /api/environments
Authorization: Bearer {token}
Response 200 OK:
{
"environments": [
{
"id": "production",
"name": "Production",
"dockerHost": "tcp://192.168.1.10:2375",
"isDefault": true,
"createdAt": "2025-01-19T10:35:00Z"
},
{
"id": "test",
"name": "Test Environment",
"dockerHost": "tcp://192.168.1.20:2375",
"isDefault": false,
"createdAt": "2025-01-20T14:20:00Z"
}
]
}Create Environment
POST /api/environments
Authorization: Bearer {token}
Content-Type: application/json
{
"id": "staging",
"name": "Staging Environment",
"dockerHost": "tcp://192.168.1.30:2375"
}
Response 201 Created:
{
"id": "staging",
"name": "Staging Environment",
"dockerHost": "tcp://192.168.1.30:2375",
"isDefault": false,
"createdAt": "2025-01-20T15:00:00Z"
}Update Environment
PUT /api/environments/{id}
Authorization: Bearer {token}
Content-Type: application/json
{
"name": "Staging (Updated)",
"dockerHost": "tcp://192.168.1.35:2375"
}
Response 200 OKDelete Environment
DELETE /api/environments/{id}
Authorization: Bearer {token}
Response 204 No ContentConstraints: Cannot delete default environment or environment with active deployments.
Get Environment Connections
GET /api/environments/{id}/connections
Authorization: Bearer {token}
Response 200 OK:
{
"environmentId": "production",
"connectionMode": "Simple",
"simple": {
"transport": "amqp://rabbitmq-prod:5672",
"persistence": "Host=db-prod;Port=5432;...",
"eventStore": "esdb://eventstore-prod:2113?tls=false"
}
}Update Environment Connections
PUT /api/environments/{id}/connections
Authorization: Bearer {token}
Content-Type: application/json
{
"connectionMode": "Simple",
"simple": {
"transport": "amqp://rabbitmq-prod:5672",
"persistence": "Host=db-prod;Port=5432;...",
"eventStore": "esdb://eventstore-prod:2113?tls=false"
}
}
Response 200 OKContainer Endpoints (Environment-Scoped)
# v0.3
GET /api/containers
# v0.4
GET /api/containers?environment={environmentId}Deployment Endpoints (Environment-Scoped)
# v0.3
POST /api/deployments
# v0.4
POST /api/deployments
{
"environmentId": "production", ← NEW REQUIRED FIELD
"manifestPath": "/app/manifests/v1.0.0.json"
}Aggregate Design: Organization is the Aggregate Root, and Environment is an Entity within the Organization aggregate. This design ensures all invariants (uniqueness constraints, default environment rules) can be enforced within a single transactional boundary.
New Domain Structure:
src/ReadyStackGo.Domain/
├── Auth/
│ ├── User.cs
│ └── UserRole.cs
├── Organization/ ← NEW in v0.4
│ ├── Organization.cs ← Aggregate Root
│ ├── Environment.cs ← Abstract base class (Entity)
│ ├── DockerSocketEnvironment.cs ← Concrete implementation (v0.4 ONLY)
│ ├── DockerApiEnvironment.cs ← Future (v0.5+)
│ ├── DockerAgentEnvironment.cs ← Future (v0.5+)
│ ├── KubernetesEnvironment.cs ← Future (v2.0+)
│ ├── OrganizationId.cs ← Value Object
│ ├── EnvironmentId.cs ← Value Object
│ └── Exceptions/
│ └── OrganizationException.cs
└── Wizard/
├── WizardState.cs
└── ConnectionMode.cs
Type Hierarchy Diagram:
Organization (Aggregate Root)
└── owns → Environment* (Abstract Entity)
├── DockerSocketEnvironment (v0.4)
│ └── SocketPath: string
├── DockerApiEnvironment (v0.5+)
│ ├── ApiUrl: string
│ ├── UseTls: bool
│ └── TlsCert/Key paths
├── DockerAgentEnvironment (v0.5+)
│ ├── AgentUrl: string
│ └── AgentSecret: string
└── KubernetesEnvironment (v2.0+)
├── KubeConfigPath: string
├── Context: string
└── Namespace: string
Key Design Decision:
- Environment is NOT a separate aggregate root
- Environment entities are owned by and managed through the Organization aggregate
- This ensures transactional consistency for invariants like "exactly one default environment" and "unique Docker hosts within organization"
namespace ReadyStackGo.Domain.Organization;
public class Organization
{
public OrganizationId Id { get; private set; }
public string Name { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime UpdatedAt { get; private set; }
private readonly List<Environment> _environments = new();
public IReadOnlyCollection<Environment> Environments => _environments.AsReadOnly();
private Organization() { }
// Factory Method: Create Organization WITHOUT environments
public static Organization Create(string id, string name)
{
if (string.IsNullOrWhiteSpace(id))
throw new OrganizationException("Organization ID cannot be empty");
if (!OrganizationId.IsValid(id))
throw new OrganizationException($"Invalid organization ID format: {id}");
if (string.IsNullOrWhiteSpace(name))
throw new OrganizationException("Organization name cannot be empty");
return new Organization
{
Id = new OrganizationId(id),
Name = name,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
}
// Business Logic: Add new Docker Socket Environment (v0.4)
public DockerSocketEnvironment AddDockerSocketEnvironment(
string id,
string name,
string socketPath,
bool setAsDefault = false)
{
ValidateNewEnvironment(id);
var environment = DockerSocketEnvironment.Create(id, name, socketPath, isDefault: false);
// Invariant: Connection string must be unique (polymorphic check)
if (_environments.Any(e => e.GetConnectionString() == environment.GetConnectionString()))
throw new OrganizationException(
$"Connection '{environment.GetConnectionString()}' is already used by another environment");
_environments.Add(environment);
// If this is the first environment OR user explicitly wants it as default
if (setAsDefault || _environments.Count == 1)
{
// Remove default from all other environments
foreach (var env in _environments.Where(e => e.IsDefault && e.Id.Value != id))
{
env.UnmarkAsDefault();
}
environment.MarkAsDefault();
}
UpdatedAt = DateTime.UtcNow;
return environment;
}
// Helper method for common validation
private void ValidateNewEnvironment(string id)
{
if (_environments.Any(e => e.Id.Value == id))
throw new OrganizationException($"Environment with ID '{id}' already exists");
}
// Business Logic: Remove Environment
public void RemoveEnvironment(string environmentId)
{
var environment = _environments.FirstOrDefault(e => e.Id.Value == environmentId)
?? throw new OrganizationException($"Environment '{environmentId}' not found");
// If deleting the default environment and other environments exist, promote another to default
if (environment.IsDefault && _environments.Count > 1)
{
throw new OrganizationException(
"Cannot delete default environment. Set another environment as default first.");
}
_environments.Remove(environment);
UpdatedAt = DateTime.UtcNow;
}
// Business Logic: Change default Environment
public void SetDefaultEnvironment(string environmentId)
{
var newDefault = _environments.FirstOrDefault(e => e.Id.Value == environmentId)
?? throw new OrganizationException($"Environment '{environmentId}' not found");
// Remove default from all other environments
foreach (var env in _environments.Where(e => e.IsDefault))
{
env.UnmarkAsDefault();
}
// Set new default
newDefault.MarkAsDefault();
UpdatedAt = DateTime.UtcNow;
}
// Business Logic: Update Environment Connection (polymorphic)
public void UpdateEnvironmentConnection(string environmentId, string newConnectionString)
{
var environment = _environments.FirstOrDefault(e => e.Id.Value == environmentId)
?? throw new OrganizationException($"Environment '{environmentId}' not found");
// Invariant: Connection string must be unique (except for this environment)
if (_environments.Any(e => e.Id.Value != environmentId && e.GetConnectionString() == newConnectionString))
throw new OrganizationException(
$"Connection '{newConnectionString}' is already used by another environment");
// Type-specific update (v0.4 only supports DockerSocketEnvironment)
if (environment is DockerSocketEnvironment dockerSocketEnv)
{
dockerSocketEnv.UpdateSocketPath(newConnectionString);
}
else
{
throw new OrganizationException(
$"Cannot update connection for environment type: {environment.GetTypeName()}");
}
UpdatedAt = DateTime.UtcNow;
}
public void UpdateName(string newName)
{
if (string.IsNullOrWhiteSpace(newName))
throw new OrganizationException("Organization name cannot be empty");
Name = newName;
UpdatedAt = DateTime.UtcNow;
}
public Environment? GetDefaultEnvironment()
{
return _environments.FirstOrDefault(e => e.IsDefault);
}
public bool HasEnvironments => _environments.Count > 0;
public Environment? GetEnvironment(string environmentId)
{
return _environments.FirstOrDefault(e => e.Id.Value == environmentId);
}
}
// Value Object
public record OrganizationId
{
public string Value { get; }
public OrganizationId(string value)
{
if (!IsValid(value))
throw new OrganizationException($"Invalid organization ID: {value}");
Value = value;
}
public static bool IsValid(string id) =>
!string.IsNullOrWhiteSpace(id) &&
Regex.IsMatch(id, @"^[a-z0-9-]+$");
public override string ToString() => Value;
}Important: Environment is an Entity, not an Aggregate Root. It can only be created and modified through the Organization aggregate.
Design Pattern: Environment uses Polymorphism (Strategy Pattern) instead of enums to support different environment types (Docker Socket, Docker API, Docker Agent, Kubernetes, etc.). Each concrete environment type encapsulates its own connection logic and validation rules.
Type Hierarchy:
Environment (Abstract Base)
├── DockerSocketEnvironment (v0.4 - ONLY THIS ONE)
├── DockerApiEnvironment (v0.5+ Future)
├── DockerAgentEnvironment (v0.5+ Future)
└── KubernetesEnvironment (v2.0+ Future)
namespace ReadyStackGo.Domain.Organization;
/// <summary>
/// Abstract base class for all environment types.
/// Uses polymorphism to support different container orchestration platforms.
/// </summary>
public abstract class Environment
{
public EnvironmentId Id { get; protected set; }
public string Name { get; protected set; }
public bool IsDefault { get; protected set; }
public DateTime CreatedAt { get; protected set; }
protected Environment() { }
// Template Methods (implemented by derived classes)
/// <summary>
/// Returns the connection string for this environment type.
/// Examples: "unix:///var/run/docker.sock", "tcp://192.168.1.10:2375"
/// </summary>
public abstract string GetConnectionString();
/// <summary>
/// Validates connectivity to this environment's orchestrator.
/// </summary>
public abstract Task<bool> ValidateConnectionAsync();
/// <summary>
/// Returns human-readable type name for UI display.
/// Examples: "Docker Socket", "Docker API", "Docker Agent"
/// </summary>
public abstract string GetTypeName();
/// <summary>
/// Returns type identifier for JSON serialization discriminator.
/// Examples: "docker-socket", "docker-api", "docker-agent"
/// </summary>
public abstract string GetTypeIdentifier();
// Internal methods (only Organization aggregate can call these)
internal void MarkAsDefault()
{
IsDefault = true;
}
internal void UnmarkAsDefault()
{
IsDefault = false;
}
internal void UpdateName(string newName)
{
if (string.IsNullOrWhiteSpace(newName))
throw new OrganizationException("Environment name cannot be empty");
Name = newName;
}
}
// Value Object
public record EnvironmentId
{
public string Value { get; }
public EnvironmentId(string value)
{
if (!IsValid(value))
throw new OrganizationException($"Invalid environment ID: {value}");
Value = value;
}
public static bool IsValid(string id) =>
!string.IsNullOrWhiteSpace(id) &&
Regex.IsMatch(id, @"^[a-z0-9-]+$");
public override string ToString() => Value;
}v0.4 Implementation: Only Docker Socket environments are supported in v0.4. This type connects to a Docker daemon via Unix socket or named pipe.
namespace ReadyStackGo.Domain.Organization;
/// <summary>
/// Docker Socket environment - connects to Docker daemon via Unix socket or named pipe.
/// This is the ONLY environment type implemented in v0.4.
/// </summary>
public class DockerSocketEnvironment : Environment
{
public string SocketPath { get; private set; }
private DockerSocketEnvironment() { }
// Factory Method (internal - only Organization can create)
internal static DockerSocketEnvironment Create(
string id,
string name,
string socketPath,
bool isDefault = false)
{
if (!EnvironmentId.IsValid(id))
throw new OrganizationException($"Invalid environment ID: {id}");
if (string.IsNullOrWhiteSpace(name))
throw new OrganizationException("Environment name cannot be empty");
if (string.IsNullOrWhiteSpace(socketPath))
throw new OrganizationException("Socket path cannot be empty");
// Normalize socket path (add unix:// prefix if missing)
var normalizedPath = NormalizeSocketPath(socketPath);
ValidateSocketPath(normalizedPath);
return new DockerSocketEnvironment
{
Id = new EnvironmentId(id),
Name = name,
SocketPath = normalizedPath,
IsDefault = isDefault,
CreatedAt = DateTime.UtcNow
};
}
public override string GetConnectionString() => SocketPath;
public override async Task<bool> ValidateConnectionAsync()
{
try
{
// Strip unix:// prefix for file system check
var path = SocketPath.Replace("unix://", "").Replace("npipe://", "");
// On Linux/macOS: Check if Unix socket exists
// On Windows: Named pipe check (more complex, simplified here)
return await Task.FromResult(File.Exists(path));
}
catch
{
return false;
}
}
public override string GetTypeName() => "Docker Socket";
public override string GetTypeIdentifier() => "docker-socket";
internal void UpdateSocketPath(string newSocketPath)
{
var normalizedPath = NormalizeSocketPath(newSocketPath);
ValidateSocketPath(normalizedPath);
SocketPath = normalizedPath;
}
private static string NormalizeSocketPath(string path)
{
if (path.StartsWith("unix://") || path.StartsWith("npipe://"))
return path;
// Auto-detect platform and add appropriate prefix
if (OperatingSystem.IsWindows())
return $"npipe://{path}";
else
return $"unix://{path}";
}
private static void ValidateSocketPath(string path)
{
if (!path.StartsWith("unix://") && !path.StartsWith("npipe://"))
throw new OrganizationException($"Invalid socket path: {path}. Must start with unix:// or npipe://");
}
}These types will be implemented in future releases:
DockerApiEnvironment (v0.5+):
/// <summary>
/// Docker API environment - connects to Docker daemon via TCP (HTTP/HTTPS).
/// Supports TLS authentication.
/// </summary>
public class DockerApiEnvironment : Environment
{
public string ApiUrl { get; private set; } // e.g., "tcp://192.168.1.10:2375"
public bool UseTls { get; private set; }
public string? TlsCertPath { get; private set; } // Optional client certificate
public string? TlsKeyPath { get; private set; } // Optional client key
internal static DockerApiEnvironment Create(
string id,
string name,
string apiUrl,
bool useTls = false,
string? tlsCertPath = null,
string? tlsKeyPath = null,
bool isDefault = false)
{
// Validation logic...
throw new NotImplementedException("Docker API environments will be implemented in v0.5");
}
public override string GetConnectionString() => ApiUrl;
public override async Task<bool> ValidateConnectionAsync() { /* HTTP ping */ }
public override string GetTypeName() => "Docker API";
public override string GetTypeIdentifier() => "docker-api";
}DockerAgentEnvironment (v0.5+):
/// <summary>
/// Docker Agent environment - connects to Portainer Edge Agent.
/// </summary>
public class DockerAgentEnvironment : Environment
{
public string AgentUrl { get; private set; } // e.g., "tcp://192.168.1.20:9001"
public string AgentSecret { get; private set; } // Edge agent secret key
internal static DockerAgentEnvironment Create(
string id,
string name,
string agentUrl,
string agentSecret,
bool isDefault = false)
{
throw new NotImplementedException("Docker Agent environments will be implemented in v0.5");
}
public override string GetConnectionString() => AgentUrl;
public override async Task<bool> ValidateConnectionAsync() { /* Agent ping */ }
public override string GetTypeName() => "Docker Agent";
public override string GetTypeIdentifier() => "docker-agent";
}KubernetesEnvironment (v2.0+):
/// <summary>
/// Kubernetes environment - connects to a Kubernetes cluster.
/// </summary>
public class KubernetesEnvironment : Environment
{
public string KubeConfigPath { get; private set; }
public string Context { get; private set; }
public string Namespace { get; private set; }
public override string GetConnectionString() => $"{Context}@{Namespace}";
public override async Task<bool> ValidateConnectionAsync() { /* kubectl ping */ }
public override string GetTypeName() => "Kubernetes";
public override string GetTypeIdentifier() => "kubernetes";
}Organization:
- ID must be lowercase alphanumeric with hyphens only (
^[a-z0-9-]+$) - Name cannot be empty
- IDs are immutable once created
- Can exist without any environments (flexible setup)
- If environments exist, at most one can be marked as default
- First added environment automatically becomes default
-
Connection strings must be unique across all environments (polymorphic validation via
GetConnectionString())
Environment (Base Class):
- ID must be lowercase alphanumeric with hyphens only (
^[a-z0-9-]+$) - Name cannot be empty
- Environment IDs are immutable once created
- Cannot delete default environment unless another environment is set as default first
- Can delete all environments (organization can exist without environments)
DockerSocketEnvironment (v0.4 Specific):
- Socket path cannot be empty
- Socket path must start with
unix://(Linux/macOS) ornpipe://(Windows) - Auto-normalization:
/var/run/docker.sock→unix:///var/run/docker.sock - Connection string uniqueness enforced at Organization aggregate level
Future Environment Types (v0.5+):
- DockerApiEnvironment: API URL validation, optional TLS certificate paths
- DockerAgentEnvironment: Agent URL validation, secret key required
- KubernetesEnvironment: Kubeconfig path validation, context and namespace validation
Benefits:
- Encapsulation: Business rules are enforced in the domain, not scattered across services
-
Type Safety:
OrganizationIdandEnvironmentIdprevent string-based errors - Polymorphism: Environment types use Strategy Pattern instead of enums for extensibility
- Testability: Domain logic can be tested independently without infrastructure
- Maintainability: Clear separation between domain logic and persistence
- Scalability: Easy to extend with new environment types without modifying existing code
- Transactional Consistency: Single aggregate = single transaction boundary = ACID guarantees
Decision: Continue using JSON files for v0.4 to maintain simplicity and consistency with v0.3.
Rationale:
- ✅ No external database setup required
- ✅ Simple deployment (mount
/app/configvolume) - ✅ Easy backup and restore (copy config directory)
- ✅ Human-readable configuration
- ✅ Consistent with v0.3 architecture
- ✅ Adequate for single-user, single-organization use case
File Structure:
/app/config/
rsgo.system.json ← Organization + Environments
rsgo.security.json ← Admin credentials
rsgo.contexts.production.json ← Production environment connections
rsgo.contexts.test.json ← Test environment connections
rsgo.tls.json ← TLS configuration
System Configuration Schema (rsgo.system.json):
{
"organization": {
"id": "acme-corp",
"name": "Acme Corporation",
"createdAt": "2025-01-19T10:00:00Z",
"updatedAt": "2025-01-20T14:30:00Z",
"environments": [
{
"$type": "docker-socket",
"id": "production",
"name": "Production",
"socketPath": "unix:///var/run/docker.sock",
"isDefault": true,
"createdAt": "2025-01-19T10:00:00Z"
},
{
"$type": "docker-socket",
"id": "test",
"name": "Test Environment",
"socketPath": "tcp://192.168.1.20:2375",
"isDefault": false,
"createdAt": "2025-01-20T14:30:00Z"
}
]
},
"wizardState": "Installed",
"installedVersion": "v0.4.0"
}Repository Implementation:
public class OrganizationRepository : IOrganizationRepository
{
private readonly string _configPath = "/app/config/rsgo.system.json";
private readonly SemaphoreSlim _lock = new(1, 1); // Prevent concurrent writes
private readonly JsonSerializerOptions _jsonOptions;
public async Task<Organization> GetAsync()
{
await _lock.WaitAsync();
try
{
if (!File.Exists(_configPath))
throw new InvalidOperationException("Organization not found. Complete the wizard first.");
var json = await File.ReadAllTextAsync(_configPath);
var dto = JsonSerializer.Deserialize<SystemConfigDto>(json, _jsonOptions);
return MapToDomain(dto.Organization);
}
finally
{
_lock.Release();
}
}
public async Task SaveAsync(Organization organization)
{
await _lock.WaitAsync();
try
{
var json = File.Exists(_configPath)
? await File.ReadAllTextAsync(_configPath)
: "{}";
var dto = JsonSerializer.Deserialize<SystemConfigDto>(json, _jsonOptions)
?? new SystemConfigDto();
dto.Organization = MapToDto(organization);
dto.UpdatedAt = DateTime.UtcNow;
var updatedJson = JsonSerializer.Serialize(dto, _jsonOptions);
await File.WriteAllTextAsync(_configPath, updatedJson);
}
finally
{
_lock.Release();
}
}
private Organization MapToDomain(OrganizationDto dto)
{
// Create organization without environments
var org = Organization.Create(dto.Id, dto.Name);
// Add environments if any exist (organization can exist without environments)
if (dto.Environments != null && dto.Environments.Count > 0)
{
// Find default environment (if one exists)
var defaultEnv = dto.Environments.FirstOrDefault(e => e.IsDefault);
// Add default environment first (if exists)
if (defaultEnv is DockerSocketEnvironment defaultSocketEnv)
{
org.AddDockerSocketEnvironment(
defaultSocketEnv.Id.Value,
defaultSocketEnv.Name,
defaultSocketEnv.SocketPath,
setAsDefault: true);
}
// Add remaining environments (polymorphic, but v0.4 only supports DockerSocket)
foreach (var env in dto.Environments.Where(e => !e.IsDefault))
{
if (env is DockerSocketEnvironment socketEnv)
{
org.AddDockerSocketEnvironment(
socketEnv.Id.Value,
socketEnv.Name,
socketEnv.SocketPath,
setAsDefault: false);
}
else
{
// Future-proofing: Skip unsupported types instead of throwing
// This allows forward compatibility when loading v0.5+ configs in v0.4
_logger.LogWarning(
"Skipping unsupported environment type '{Type}' (ID: {Id}). Upgrade to a newer version to use this environment.",
env.GetTypeName(), env.Id.Value);
}
}
}
return org;
}
}Concurrency Handling:
-
SemaphoreSlimprevents concurrent file writes - Last-write-wins strategy (acceptable for single-user scenario)
- Future: Optimistic concurrency with version numbers
Limitations:
⚠️ No ACID transactions (file write is atomic, but not with other config files)⚠️ Not suitable for multi-user scenarios (no locking across instances)⚠️ Performance degrades with many environments (>100)
When to migrate:
- Multi-user support is added
- More than ~20 environments per organization
- Need for advanced querying or reporting
Benefits of SQLite:
- ✅ ACID transactions
- ✅ Better concurrency handling
- ✅ EF Core support (migrations, LINQ queries)
- ✅ Still file-based (no external database)
- ✅ Easy backup (single
.dbfile)
Migration Plan:
- Keep
IOrganizationRepositoryinterface unchanged - Create
SqliteOrganizationRepositoryimplementation - Provide migration tool:
rsgo.system.json→readystackgo.db - Update documentation
Schema (EF Core):
public class OrganizationConfiguration : IEntityTypeConfiguration<Organization>
{
public void Configure(EntityTypeBuilder<Organization> builder)
{
builder.ToTable("Organizations");
builder.HasKey(o => o.Id);
builder.Property(o => o.Id)
.HasConversion(
id => id.Value,
value => new OrganizationId(value));
builder.Property(o => o.Name).IsRequired();
builder.Property(o => o.CreatedAt).IsRequired();
builder.Property(o => o.UpdatedAt).IsRequired();
// Environments as owned entities
builder.OwnsMany(o => o.Environments, env =>
{
env.ToTable("Environments");
env.WithOwner().HasForeignKey("OrganizationId");
env.Property(e => e.Id)
.HasConversion(
id => id.Value,
value => new EnvironmentId(value));
env.Property(e => e.Name).IsRequired();
env.Property(e => e.DockerHost).IsRequired();
env.Property(e => e.IsDefault).IsRequired();
env.Property(e => e.CreatedAt).IsRequired();
env.HasIndex(e => new { e.OrganizationId, e.DockerHost }).IsUnique();
});
}
}Recommendation: Defer SQLite migration to v0.5 or v0.6 when multi-user support is added.
Challenge: Polymorphic environment types must be correctly serialized/deserialized to/from JSON. System.Text.Json requires custom converters to handle type hierarchies.
Solution: Implement a custom JsonConverter<Environment> that uses the $type field as a discriminator.
EnvironmentJsonConverter Implementation:
public class EnvironmentJsonConverter : JsonConverter<Environment>
{
private const string TypeDiscriminatorProperty = "$type";
public override Environment Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
// Read the entire JSON object into a JsonDocument
using var jsonDoc = JsonDocument.ParseValue(ref reader);
var root = jsonDoc.RootElement;
// Extract the $type discriminator
if (!root.TryGetProperty(TypeDiscriminatorProperty, out var typeProperty))
throw new JsonException("Missing $type property in Environment JSON");
var typeIdentifier = typeProperty.GetString();
// Deserialize to the correct concrete type
return typeIdentifier switch
{
"docker-socket" => JsonSerializer.Deserialize<DockerSocketEnvironment>(
root.GetRawText(), options)!,
"docker-api" => JsonSerializer.Deserialize<DockerApiEnvironment>(
root.GetRawText(), options)!,
"docker-agent" => JsonSerializer.Deserialize<DockerAgentEnvironment>(
root.GetRawText(), options)!,
"kubernetes" => JsonSerializer.Deserialize<KubernetesEnvironment>(
root.GetRawText(), options)!,
_ => throw new JsonException($"Unknown environment type: {typeIdentifier}")
};
}
public override void Write(
Utf8JsonWriter writer,
Environment value,
JsonSerializerOptions options)
{
// Start writing the JSON object
writer.WriteStartObject();
// Write the $type discriminator first
writer.WriteString(TypeDiscriminatorProperty, value.GetTypeIdentifier());
// Write base properties
writer.WriteString("id", value.Id.Value);
writer.WriteString("name", value.Name);
writer.WriteBoolean("isDefault", value.IsDefault);
writer.WriteString("createdAt", value.CreatedAt.ToString("o")); // ISO 8601
// Write type-specific properties
switch (value)
{
case DockerSocketEnvironment dockerSocket:
writer.WriteString("socketPath", dockerSocket.SocketPath);
break;
case DockerApiEnvironment dockerApi:
writer.WriteString("apiUrl", dockerApi.ApiUrl);
writer.WriteBoolean("useTls", dockerApi.UseTls);
if (dockerApi.TlsCertPath != null)
writer.WriteString("tlsCertPath", dockerApi.TlsCertPath);
if (dockerApi.TlsKeyPath != null)
writer.WriteString("tlsKeyPath", dockerApi.TlsKeyPath);
break;
case DockerAgentEnvironment dockerAgent:
writer.WriteString("agentUrl", dockerAgent.AgentUrl);
writer.WriteString("agentSecret", dockerAgent.AgentSecret);
break;
case KubernetesEnvironment kubernetes:
writer.WriteString("kubeConfigPath", kubernetes.KubeConfigPath);
writer.WriteString("context", kubernetes.Context);
writer.WriteString("namespace", kubernetes.Namespace);
break;
default:
throw new JsonException($"Unsupported environment type: {value.GetType().Name}");
}
writer.WriteEndObject();
}
}Registration in ConfigStore:
public class ConfigStore : IConfigStore
{
private readonly JsonSerializerOptions _jsonOptions;
public ConfigStore()
{
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
Converters = { new EnvironmentJsonConverter() } // Register custom converter
};
}
// ... rest of ConfigStore implementation
}Benefits:
- ✅ Single
$typefield drives deserialization - ✅ No need for separate JSON files per type
- ✅ Easy to add new environment types (just add case to switch)
- ✅ Type-safe deserialization with proper domain objects
Example JSON with Mixed Types (v0.5+):
{
"organization": {
"id": "acme-corp",
"name": "Acme Corporation",
"environments": [
{
"$type": "docker-socket",
"id": "local-dev",
"name": "Local Development",
"socketPath": "unix:///var/run/docker.sock",
"isDefault": false,
"createdAt": "2025-01-19T10:00:00Z"
},
{
"$type": "docker-api",
"id": "production",
"name": "Production Cluster",
"apiUrl": "tcp://prod-docker.acme.local:2376",
"useTls": true,
"tlsCertPath": "/app/config/tls/client-cert.pem",
"tlsKeyPath": "/app/config/tls/client-key.pem",
"isDefault": true,
"createdAt": "2025-01-19T11:00:00Z"
},
{
"$type": "docker-agent",
"id": "remote-edge",
"name": "Edge Location",
"agentUrl": "tcp://edge.acme.local:9001",
"agentSecret": "••••••••",
"isDefault": false,
"createdAt": "2025-01-20T09:00:00Z"
}
]
}
}Old Approach (v0.3 - WRONG):
- Connection strings configured globally in wizard Step 3
- All stacks share the same connection strings
- No flexibility for different stack requirements
- Connection strings stored in
rsgo.contexts.json
New Approach (v0.4+ - CORRECT):
- Connection strings are stack-specific, not global
- Each stack deployment can have different configuration values
- Configuration happens during stack deployment, not during wizard setup
- Supports multiple stack formats (prioritized by implementation phase)
Phase 1 - v0.4: Docker Compose Format (Portainer-style)
- ✅ Use standard
docker-compose.ymlfiles - ✅ Automatic environment variable detection from
${VARIABLE}syntax - ✅ Dynamic UI generation based on detected variables
- ✅ Quick deployment of existing stacks
- ✅ Familiar format for Docker users
⚠️ Limited validation (no type checking, no required field enforcement)
Phase 2 - v0.5+: Custom Manifest Format (Enhanced)
- ✅ Full validation with type checking and regex patterns
- ✅ Required field enforcement
- ✅ Display names and descriptions for better UX
- ✅ Sensitive field marking
- ✅ Default values and documentation
- ✅ Advanced configuration types (numbers, paths, database connections)
Rationale:
- Docker Compose allows immediate deployment of existing stacks
- Custom manifest format can be added later as optional enhancement
- Both formats can coexist (users choose which to use)
File: docker-compose.yml
version: '3.8'
services:
api-gateway:
image: readystack/api-gateway:1.0.0
ports:
- "${API_PORT:-8080}:8080"
environment:
- TRANSPORT_URL=${TRANSPORT_URL}
- PERSISTENCE_CONNECTION=${PERSISTENCE_CONNECTION}
- EVENTSTORE_URL=${EVENTSTORE_URL:-esdb://eventstore:2113}
depends_on:
- postgres
- rabbitmq
- eventstore
order-service:
image: readystack/order-service:1.0.0
ports:
- "${ORDER_SERVICE_PORT:-8081}:8080"
environment:
- TRANSPORT_URL=${TRANSPORT_URL}
- PERSISTENCE_CONNECTION=${PERSISTENCE_CONNECTION}
- EVENTSTORE_URL=${EVENTSTORE_URL:-esdb://eventstore:2113}
depends_on:
- postgres
- rabbitmq
- eventstore
postgres:
image: postgres:15
environment:
- POSTGRES_DB=${POSTGRES_DB:-readystack}
- POSTGRES_USER=${POSTGRES_USER:-admin}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
rabbitmq:
image: rabbitmq:3-management
ports:
- "5672:5672"
- "15672:15672"
eventstore:
image: eventstore/eventstore:latest
environment:
- EVENTSTORE_INSECURE=true
ports:
- "2113:2113"
volumes:
postgres-data:How ReadyStackGo Detects Variables:
-
Parse
docker-compose.ymlusing YAML parser -
Scan all
environmentsections for${VARIABLE}or${VARIABLE:-default}patterns -
Extract variable names and defaults:
-
${TRANSPORT_URL}→ Variable:TRANSPORT_URL, Default:null -
${API_PORT:-8080}→ Variable:API_PORT, Default:8080
-
- Generate UI input fields dynamically
- Store configuration per deployment
C# Implementation (Pseudocode):
public class DockerComposeParser
{
public List<EnvironmentVariable> ExtractVariables(string composeYaml)
{
var variables = new List<EnvironmentVariable>();
var yaml = new YamlStream();
yaml.Load(new StringReader(composeYaml));
var root = (YamlMappingNode)yaml.Documents[0].RootNode;
var services = (YamlMappingNode)root.Children["services"];
foreach (var service in services.Children)
{
var serviceNode = (YamlMappingNode)service.Value;
if (serviceNode.Children.ContainsKey("environment"))
{
var envNode = serviceNode.Children["environment"];
if (envNode is YamlSequenceNode envList)
{
foreach (var envItem in envList.Children)
{
var envString = ((YamlScalarNode)envItem).Value;
// Match patterns: ${VAR} or ${VAR:-default}
var matches = Regex.Matches(envString, @"\$\{([^}:]+)(?::-(.*))?\}");
foreach (Match match in matches)
{
var varName = match.Groups[1].Value;
var defaultValue = match.Groups[2].Success ? match.Groups[2].Value : null;
if (!variables.Any(v => v.Name == varName))
{
variables.Add(new EnvironmentVariable
{
Name = varName,
DefaultValue = defaultValue,
DisplayName = FormatDisplayName(varName), // "TRANSPORT_URL" → "Transport URL"
Required = defaultValue == null
});
}
}
}
}
}
}
return variables;
}
private string FormatDisplayName(string varName)
{
// Convert "TRANSPORT_URL" to "Transport URL"
return string.Join(" ", varName.Split('_')
.Select(word => char.ToUpper(word[0]) + word.Substring(1).ToLower()));
}
}
public class EnvironmentVariable
{
public string Name { get; set; }
public string? DefaultValue { get; set; }
public string DisplayName { get; set; }
public bool Required { get; set; }
}When a stack is deployed to an environment, its configuration is stored per deployment instance:
File: /app/config/deployments/{environmentId}/{stackName}.deployment.json
{
"deploymentId": "deployment-12345",
"environmentId": "production",
"stackName": "readystack-core",
"composeFile": "docker-compose.yml",
"deployedAt": "2025-01-20T10:00:00Z",
"configuration": {
"TRANSPORT_URL": "amqp://prod-rabbitmq.acme.local:5672",
"PERSISTENCE_CONNECTION": "Host=prod-db.acme.local;Port=5432;Database=readystack_prod;Username=prod_admin;Password=***",
"EVENTSTORE_URL": "esdb://prod-eventstore.acme.local:2113?tls=true",
"API_PORT": "8080",
"ORDER_SERVICE_PORT": "8081",
"POSTGRES_DB": "readystack_prod",
"POSTGRES_USER": "admin",
"POSTGRES_PASSWORD": "***"
},
"containers": [
{
"serviceName": "api-gateway",
"containerId": "abc123def456",
"status": "running",
"ports": ["8080:8080"]
},
{
"serviceName": "order-service",
"containerId": "def456ghi789",
"status": "running",
"ports": ["8081:8080"]
},
{
"serviceName": "postgres",
"containerId": "ghi789jkl012",
"status": "running",
"ports": []
},
{
"serviceName": "rabbitmq",
"containerId": "jkl012mno345",
"status": "running",
"ports": ["5672:5672", "15672:15672"]
},
{
"serviceName": "eventstore",
"containerId": "mno345pqr678",
"status": "running",
"ports": ["2113:2113"]
}
]
}Key Properties:
-
deploymentId: Unique identifier for this deployment -
environmentId: Which environment the stack is deployed to -
stackName: Name of the stack (derived from compose file or user input) -
composeFile: Original compose file name -
configuration: Flat key-value pairs for all environment variables -
containers: List of deployed containers with Docker IDs
-
User uploads
docker-compose.ymlvia UI or selects from existing stacks - System parses compose file and extracts environment variables
- Selects environment (e.g., "Production")
-
Configuration UI appears - dynamically generated from detected variables
- Shows all detected variables
- Pre-fills defaults from
${VAR:-default}syntax - Marks variables without defaults as required
-
User provides/confirms values
-
TRANSPORT_URL:amqp://prod-rabbitmq:5672 -
PERSISTENCE_CONNECTION:Host=prod-db;Port=5432;Database=readystack_prod;... -
EVENTSTORE_URL:esdb://prod-es:2113(default already filled) -
POSTGRES_PASSWORD:***(required, no default)
-
- System validates basic requirements (non-empty required fields)
- Deployment starts - Docker Compose executed with environment variables
- Configuration saved to deployment file
┌─────────────────────────────────────────────────────────────┐
│ Deploy Stack from Docker Compose │
│ Environment: Production │
├─────────────────────────────────────────────────────────────┤
│ │
│ Stack Configuration │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Stack Name * │ │
│ │ ┌──────────────────────────────────────────────────────┐│ │
│ │ │ readystack-core ││ │
│ │ └──────────────────────────────────────────────────────┘│ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Environment Variables (8 detected) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ TRANSPORT_URL * │ │
│ │ ┌──────────────────────────────────────────────────────┐│ │
│ │ │ amqp://prod-rabbitmq.acme.local:5672 ││ │
│ │ └──────────────────────────────────────────────────────┘│ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ PERSISTENCE_CONNECTION * │ │
│ │ ┌──────────────────────────────────────────────────────┐│ │
│ │ │ Host=prod-db;Port=5432;Database=readystack_prod;... ││ │
│ │ └──────────────────────────────────────────────────────┘│ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ EVENTSTORE_URL │ │
│ │ ┌──────────────────────────────────────────────────────┐│ │
│ │ │ esdb://eventstore:2113 ││ │ ← Default from compose
│ │ └──────────────────────────────────────────────────────┘│ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ POSTGRES_PASSWORD * │ │
│ │ ┌──────────────────────────────────────────────────────┐│ │
│ │ │ •••••••• ││ │ ← Password field
│ │ └──────────────────────────────────────────────────────┘│ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ [Show all variables (4 more)] ▼ │
│ │
│ [Cancel] [Deploy Stack] │
└─────────────────────────────────────────────────────────────┘
Key Features:
- ✅ Variables without defaults marked with
*(required) - ✅ Variables with defaults pre-filled (can be overridden)
- ✅ Collapsible section for less important variables
- ✅ Automatic password field detection (contains "PASSWORD", "SECRET", "TOKEN")
- ✅ Standard Format: Docker Compose is industry-standard, widely known
- ✅ Quick Start: Deploy existing stacks immediately without conversion
- ✅ Stack-Specific: Each stack can have different configuration requirements
- ✅ Environment-Specific: Same stack can have different configs per environment
- ✅ No Global State: No wizard step for connection strings
- ✅ Flexibility: Easy to add new configuration parameters by editing compose file
- ✅ Portainer Compatibility: Familiar workflow for existing Portainer users
⚠️ No Type Validation: All values are strings, no number/boolean validation⚠️ No Regex Validation: Can't enforce format patterns (e.g., URL format)⚠️ Basic Required Check: Only checks if value is non-empty⚠️ No Sensitive Marking: Must detect password fields by naming convention⚠️ Limited Documentation: No field descriptions or help text
Solution: These limitations will be addressed in Phase 2 (Custom Manifest Format in v0.5+).
Status: 🚧 Planned for v0.5 or later
This phase adds an optional enhanced format for stacks that need advanced validation and documentation.
File: readystack-core.manifest.json
{
"version": "1.0.0",
"name": "ReadyStack Core",
"description": "Core microservices for ReadyStack platform",
"format": "manifest",
"containers": [
{
"name": "api-gateway",
"image": "readystack/api-gateway:1.0.0",
"ports": ["8080:8080"],
"environment": [
"TRANSPORT_URL={{transport.url}}",
"PERSISTENCE_CONNECTION={{persistence.connection}}",
"EVENTSTORE_URL={{eventstore.url}}"
]
},
{
"name": "order-service",
"image": "readystack/order-service:1.0.0",
"ports": ["8081:8080"],
"environment": [
"TRANSPORT_URL={{transport.url}}",
"PERSISTENCE_CONNECTION={{persistence.connection}}",
"EVENTSTORE_URL={{eventstore.url}}"
]
}
],
"configurationSchema": {
"transport": {
"url": {
"type": "string",
"displayName": "Message Transport URL",
"description": "RabbitMQ connection string (AMQP protocol)",
"default": "amqp://rabbitmq:5672",
"required": true,
"validation": "^amqps?://.*",
"placeholder": "amqp://hostname:5672"
}
},
"persistence": {
"connection": {
"type": "string",
"displayName": "Database Connection String",
"description": "PostgreSQL connection string",
"default": "Host=postgres;Port=5432;Database=readystack;Username=admin",
"required": true,
"sensitive": true,
"placeholder": "Host=hostname;Port=5432;Database=dbname;Username=user;Password=pwd"
}
},
"eventstore": {
"url": {
"type": "string",
"displayName": "Event Store URL",
"description": "EventStoreDB connection string",
"default": "esdb://eventstore:2113?tls=false",
"required": true,
"validation": "^esdb://.*",
"placeholder": "esdb://hostname:2113"
}
}
}
}Configuration Value Types (v0.5+):
{
"configurationSchema": {
"example": {
"stringField": {
"type": "string",
"displayName": "Example String",
"description": "A string field with validation",
"default": "default-value",
"required": true,
"validation": "^[a-z0-9-]+$",
"placeholder": "enter-value-here"
},
"numberField": {
"type": "number",
"displayName": "Example Number",
"description": "A number field with range",
"default": 8080,
"required": true,
"min": 1024,
"max": 65535
},
"booleanField": {
"type": "boolean",
"displayName": "Example Boolean",
"description": "A boolean toggle",
"default": true
},
"selectField": {
"type": "select",
"displayName": "Example Dropdown",
"description": "A dropdown selection",
"default": "option1",
"options": ["option1", "option2", "option3"]
},
"secretField": {
"type": "string",
"displayName": "Example Secret",
"description": "A sensitive field",
"required": true,
"sensitive": true
}
}
}
}- ✅ Type Validation: Numbers, booleans, strings, selects
- ✅ Regex Validation: Enforce URL formats, patterns, etc.
- ✅ Range Validation: Min/max for numbers
- ✅ Required Fields: Explicit required marking
- ✅ Sensitive Fields: Explicit password/secret marking
- ✅ Documentation: Display names, descriptions, placeholders
- ✅ Better UX: More user-friendly configuration UI
v0.5+ will support BOTH formats:
User uploads stack:
├─ docker-compose.yml → Parsed as Docker Compose (Phase 1 logic)
└─ *.manifest.json → Parsed as Custom Manifest (Phase 2 logic)
No migration required: Existing Docker Compose stacks continue to work. Users can optionally upgrade to custom manifests when they need advanced features.
v0.3 had:
-
rsgo.contexts.jsonwith global connection strings (Simple mode)
v0.4 Migration Strategy:
- Read existing
rsgo.contexts.jsonon first startup - When user deploys first stack in v0.4:
- Pre-fill deployment configuration UI with values from
rsgo.contexts.json - Map v0.3 context names to v0.4 environment variables:
-
Transport→TRANSPORT_URL -
Persistence→PERSISTENCE_CONNECTION -
EventStore→EVENTSTORE_URL
-
- User can confirm or modify values
- Pre-fill deployment configuration UI with values from
- Save configuration per stack deployment
- Archive
rsgo.contexts.json→rsgo.contexts.json.v0.3.backup - Remove wizard step for connection configuration
Example Migration:
// v0.3: rsgo.contexts.json (OLD)
{
"transport": "amqp://rabbitmq:5672",
"persistence": "Host=postgres;Port=5432;Database=readystack",
"eventStore": "esdb://eventstore:2113?tls=false"
}
// v0.4: Pre-filled deployment UI (NEW)
{
"TRANSPORT_URL": "amqp://rabbitmq:5672",
"PERSISTENCE_CONNECTION": "Host=postgres;Port=5432;Database=readystack",
"EVENTSTORE_URL": "esdb://eventstore:2113?tls=false"
}Upload Compose File
POST /api/stacks/upload
Authorization: Bearer {token}
Content-Type: multipart/form-data
File: docker-compose.yml
Response 200 OK:
{
"stackName": "readystack-core",
"detectedVariables": [
{
"name": "TRANSPORT_URL",
"displayName": "Transport URL",
"required": true,
"defaultValue": null
},
{
"name": "EVENTSTORE_URL",
"displayName": "Eventstore URL",
"required": false,
"defaultValue": "esdb://eventstore:2113"
}
],
"services": ["api-gateway", "order-service", "postgres", "rabbitmq", "eventstore"]
}Deploy Docker Compose Stack
POST /api/deployments
Authorization: Bearer {token}
Content-Type: application/json
{
"environmentId": "production",
"stackName": "readystack-core",
"composeFile": "docker-compose.yml",
"configuration": {
"TRANSPORT_URL": "amqp://prod-rabbitmq:5672",
"PERSISTENCE_CONNECTION": "Host=prod-db;Port=5432;Database=readystack_prod;...",
"EVENTSTORE_URL": "esdb://prod-es:2113?tls=true",
"POSTGRES_PASSWORD": "secure-password-here"
}
}
Response 201 Created:
{
"deploymentId": "deployment-12345",
"environmentId": "production",
"stackName": "readystack-core",
"status": "deploying",
"containers": [
{ "serviceName": "api-gateway", "status": "creating" },
{ "serviceName": "order-service", "status": "creating" },
{ "serviceName": "postgres", "status": "creating" },
{ "serviceName": "rabbitmq", "status": "creating" },
{ "serviceName": "eventstore", "status": "creating" }
]
}GET /api/deployments/{deploymentId}/configuration
Authorization: Bearer {token}
Response 200 OK:
{
"deploymentId": "deployment-12345",
"environmentId": "production",
"stackName": "readystack-core",
"configuration": {
"TRANSPORT_URL": "amqp://prod-rabbitmq:5672",
"PERSISTENCE_CONNECTION": "Host=prod-db;Port=5432;Database=readystack_prod;...",
"EVENTSTORE_URL": "esdb://prod-es:2113?tls=true",
"POSTGRES_PASSWORD": "***" // Masked for security
}
}POST /api/deployments
Authorization: Bearer {token}
Content-Type: application/json
{
"environmentId": "production",
"manifestFile": "readystack-core.manifest.json",
"configuration": {
"transport": {
"url": "amqp://prod-rabbitmq:5672"
},
"persistence": {
"connection": "Host=prod-db;..."
}
}
}public interface IEnvironmentService
{
Task<List<Environment>> GetAllEnvironmentsAsync();
Task<Environment?> GetEnvironmentAsync(string environmentId);
Task<Environment> CreateEnvironmentAsync(CreateEnvironmentRequest request);
Task<Environment> UpdateEnvironmentAsync(string environmentId, UpdateEnvironmentRequest request);
Task DeleteEnvironmentAsync(string environmentId);
Task<Environment> GetDefaultEnvironmentAsync();
Task SetDefaultEnvironmentAsync(string environmentId);
}
public class EnvironmentService : IEnvironmentService
{
private readonly IConfigStore _configStore;
private readonly ILogger<EnvironmentService> _logger;
public async Task<List<Environment>> GetAllEnvironmentsAsync()
{
var systemConfig = await _configStore.GetSystemConfigAsync();
return systemConfig.Environments;
}
public async Task<Environment> CreateEnvironmentAsync(CreateEnvironmentRequest request)
{
var systemConfig = await _configStore.GetSystemConfigAsync();
// Validate unique environment ID
if (systemConfig.Environments.Any(e => e.Id == request.Id))
{
throw new InvalidOperationException($"Environment with ID '{request.Id}' already exists.");
}
// Validate unique Docker host URL
if (systemConfig.Environments.Any(e => e.DockerHost == request.DockerHost))
{
throw new InvalidOperationException(
$"Docker host '{request.DockerHost}' is already used by another environment. " +
"Each environment must have a unique Docker host.");
}
var newEnvironment = new Environment
{
Id = request.Id,
Name = request.Name,
DockerHost = request.DockerHost,
IsDefault = systemConfig.Environments.Count == 0, // First environment is default
CreatedAt = DateTime.UtcNow
};
systemConfig.Environments.Add(newEnvironment);
await _configStore.SaveSystemConfigAsync(systemConfig);
// Create empty contexts config for new environment
var contextsConfig = new ContextsConfig
{
EnvironmentId = newEnvironment.Id,
ConnectionMode = ConnectionMode.Simple,
Simple = new SimpleConnectionConfig()
};
await _configStore.SaveContextsConfigAsync(newEnvironment.Id, contextsConfig);
return newEnvironment;
}
// ... other methods
}public interface IConfigStore
{
// Existing methods
Task<SystemConfig> GetSystemConfigAsync();
Task SaveSystemConfigAsync(SystemConfig config);
Task<SecurityConfig> GetSecurityConfigAsync();
Task SaveSecurityConfigAsync(SecurityConfig config);
Task<TlsConfig> GetTlsConfigAsync();
Task SaveTlsConfigAsync(TlsConfig config);
// v0.3 - Single contexts file
Task<ContextsConfig> GetContextsConfigAsync();
Task SaveContextsConfigAsync(ContextsConfig config);
// v0.4 - Per-environment contexts files (NEW)
Task<ContextsConfig> GetContextsConfigAsync(string environmentId);
Task SaveContextsConfigAsync(string environmentId, ContextsConfig config);
}Implementation:
public async Task<ContextsConfig> GetContextsConfigAsync(string environmentId)
{
var fileName = $"rsgo.contexts.{environmentId}.json";
var filePath = Path.Combine(_configDirectory, fileName);
if (!File.Exists(filePath))
{
_logger.LogWarning("Contexts config not found for environment: {EnvironmentId}", environmentId);
return new ContextsConfig { EnvironmentId = environmentId };
}
var json = await File.ReadAllTextAsync(filePath);
var config = JsonSerializer.Deserialize<ContextsConfig>(json, _jsonOptions);
return config ?? new ContextsConfig { EnvironmentId = environmentId };
}
public async Task SaveContextsConfigAsync(string environmentId, ContextsConfig config)
{
var fileName = $"rsgo.contexts.{environmentId}.json";
var filePath = Path.Combine(_configDirectory, fileName);
config.EnvironmentId = environmentId; // Ensure consistency
var json = JsonSerializer.Serialize(config, _jsonOptions);
await File.WriteAllTextAsync(filePath, json);
_logger.LogInformation("Saved contexts config for environment: {EnvironmentId}", environmentId);
}// v0.3
public interface IDockerService
{
Task<List<ContainerInfo>> ListContainersAsync();
Task<ContainerInfo> GetContainerAsync(string containerId);
Task StartContainerAsync(string containerId);
Task StopContainerAsync(string containerId);
}
// v0.4 - Environment-aware
public interface IDockerService
{
Task<List<ContainerInfo>> ListContainersAsync(string environmentId);
Task<ContainerInfo> GetContainerAsync(string environmentId, string containerId);
Task StartContainerAsync(string environmentId, string containerId);
Task StopContainerAsync(string environmentId, string containerId);
Task<bool> TestConnectionAsync(string dockerHostUrl); // NEW - Test Docker host connectivity
}Implementation:
public class DockerService : IDockerService
{
private readonly IEnvironmentService _environmentService;
private readonly ILogger<DockerService> _logger;
public async Task<List<ContainerInfo>> ListContainersAsync(string environmentId)
{
var environment = await _environmentService.GetEnvironmentAsync(environmentId);
if (environment == null)
{
throw new InvalidOperationException($"Environment '{environmentId}' not found.");
}
var client = CreateDockerClient(environment.DockerHost);
var containers = await client.Containers.ListContainersAsync(new ContainersListParameters
{
All = true
});
return containers.Select(c => new ContainerInfo
{
Id = c.ID,
Name = c.Names.FirstOrDefault()?.TrimStart('/') ?? "unknown",
Image = c.Image,
State = c.State,
Status = c.Status
}).ToList();
}
private DockerClient CreateDockerClient(string dockerHostUrl)
{
var config = new DockerClientConfiguration(new Uri(dockerHostUrl));
return config.CreateClient();
}
public async Task<bool> TestConnectionAsync(string dockerHostUrl)
{
try
{
var client = CreateDockerClient(dockerHostUrl);
await client.System.PingAsync();
return true;
}
catch
{
return false;
}
}
}Location: src/components/EnvironmentSelector.tsx
import { useState, useEffect } from 'react';
import { useEnvironment } from '../hooks/useEnvironment';
export default function EnvironmentSelector() {
const { environments, activeEnvironment, setActiveEnvironment } = useEnvironment();
const [isOpen, setIsOpen] = useState(false);
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-600"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span>{activeEnvironment?.name || 'Select Environment'}</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="absolute right-0 z-10 mt-2 w-64 bg-white border border-gray-200 rounded-lg shadow-lg dark:bg-gray-800 dark:border-gray-700">
<div className="p-2">
{environments.map((env) => (
<button
key={env.id}
onClick={() => {
setActiveEnvironment(env.id);
setIsOpen(false);
}}
className={`w-full px-4 py-2 text-left text-sm rounded-md transition-colors ${
activeEnvironment?.id === env.id
? 'bg-brand-100 text-brand-700 dark:bg-brand-900 dark:text-brand-300'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium">{env.name}</span>
{env.isDefault && (
<span className="text-xs text-gray-500 dark:text-gray-400">Default</span>
)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{env.dockerHost}
</div>
</button>
))}
</div>
<div className="border-t border-gray-200 dark:border-gray-700 p-2">
<a
href="/settings/environments"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md dark:text-gray-300 dark:hover:bg-gray-700"
>
Manage Environments
</a>
</div>
</div>
)}
</div>
);
}Location: src/hooks/useEnvironment.ts
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { getEnvironments } from '../services/api';
interface Environment {
id: string;
name: string;
dockerHost: string;
isDefault: boolean;
createdAt: string;
}
interface EnvironmentContextType {
environments: Environment[];
activeEnvironment: Environment | null;
setActiveEnvironment: (environmentId: string) => void;
refreshEnvironments: () => Promise<void>;
}
const EnvironmentContext = createContext<EnvironmentContextType | undefined>(undefined);
export function EnvironmentProvider({ children }: { children: ReactNode }) {
const [environments, setEnvironments] = useState<Environment[]>([]);
const [activeEnvironmentId, setActiveEnvironmentId] = useState<string | null>(
localStorage.getItem('activeEnvironmentId')
);
const activeEnvironment = environments.find(e => e.id === activeEnvironmentId) || null;
const refreshEnvironments = async () => {
try {
const data = await getEnvironments();
setEnvironments(data.environments);
// Set default environment if none active
if (!activeEnvironmentId && data.environments.length > 0) {
const defaultEnv = data.environments.find(e => e.isDefault) || data.environments[0];
setActiveEnvironmentId(defaultEnv.id);
localStorage.setItem('activeEnvironmentId', defaultEnv.id);
}
} catch (error) {
console.error('Failed to load environments:', error);
}
};
const setActiveEnvironment = (environmentId: string) => {
setActiveEnvironmentId(environmentId);
localStorage.setItem('activeEnvironmentId', environmentId);
};
useEffect(() => {
refreshEnvironments();
}, []);
return (
<EnvironmentContext.Provider
value={{ environments, activeEnvironment, setActiveEnvironment, refreshEnvironments }}
>
{children}
</EnvironmentContext.Provider>
);
}
export function useEnvironment() {
const context = useContext(EnvironmentContext);
if (!context) {
throw new Error('useEnvironment must be used within EnvironmentProvider');
}
return context;
}import EnvironmentSelector from '../components/EnvironmentSelector';
export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<div>
<header className="flex items-center justify-between p-4 border-b">
<h1>ReadyStackGo Admin</h1>
<div className="flex items-center gap-4">
<EnvironmentSelector /> {/* NEW */}
<UserMenu />
</div>
</header>
<main>{children}</main>
</div>
);
}import { useEnvironment } from '../hooks/useEnvironment';
export default function ContainersPage() {
const { activeEnvironment } = useEnvironment();
const [containers, setContainers] = useState([]);
useEffect(() => {
if (activeEnvironment) {
loadContainers(activeEnvironment.id);
}
}, [activeEnvironment]);
const loadContainers = async (environmentId: string) => {
const data = await getContainers(environmentId);
setContainers(data);
};
if (!activeEnvironment) {
return <div>No environment selected</div>;
}
return (
<div>
<h2>Containers - {activeEnvironment.name}</h2>
{/* Container list */}
</div>
);
}Detection Logic:
public async Task<bool> IsMigrationNeededAsync()
{
var systemConfig = await _configStore.GetSystemConfigAsync();
// Check if v0.3 format (no environments array)
return systemConfig.Environments == null || systemConfig.Environments.Count == 0;
}
public async Task MigrateFromV03Async()
{
_logger.LogInformation("Starting migration from v0.3 to v0.4");
// 1. Load existing v0.3 system config
var systemConfig = await _configStore.GetSystemConfigAsync();
// 2. Create default environment
var defaultEnvironment = new Environment
{
Id = "production",
Name = "Production",
DockerHost = "unix:///var/run/docker.sock", // Default to local Docker
IsDefault = true,
CreatedAt = DateTime.UtcNow
};
systemConfig.Environments = new List<Environment> { defaultEnvironment };
systemConfig.InstalledVersion = "v0.4.0";
systemConfig.UpdatedAt = DateTime.UtcNow;
await _configStore.SaveSystemConfigAsync(systemConfig);
// 3. Migrate rsgo.contexts.json → rsgo.contexts.production.json
var oldContextsPath = Path.Combine(_configDirectory, "rsgo.contexts.json");
var newContextsPath = Path.Combine(_configDirectory, "rsgo.contexts.production.json");
if (File.Exists(oldContextsPath))
{
var contextsJson = await File.ReadAllTextAsync(oldContextsPath);
var contextsConfig = JsonSerializer.Deserialize<ContextsConfig>(contextsJson);
if (contextsConfig != null)
{
contextsConfig.EnvironmentId = "production";
await _configStore.SaveContextsConfigAsync("production", contextsConfig);
// Backup old file
File.Move(oldContextsPath, oldContextsPath + ".v0.3.backup");
}
}
_logger.LogInformation("Migration to v0.4 completed successfully");
}Startup Hook in Program.cs:
// After TLS bootstrap, before HTTP pipeline
await MigrateConfigurationAsync(app);
private static async Task MigrateConfigurationAsync(WebApplication app)
{
using var scope = app.Services.CreateScope();
var migrationService = scope.ServiceProvider.GetRequiredService<IMigrationService>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
try
{
if (await migrationService.IsMigrationNeededAsync())
{
logger.LogInformation("Detected v0.3 configuration. Starting migration to v0.4...");
await migrationService.MigrateFromV03Async();
logger.LogInformation("Configuration migration completed successfully.");
}
}
catch (Exception ex)
{
logger.LogError(ex, "Configuration migration failed. Manual intervention may be required.");
}
}-
EnvironmentServiceCRUD operations -
ConfigStoreenvironment-specific file operations - Migration logic from v0.3 to v0.4
- Environment validation (unique IDs, default environment constraints)
-
GET /api/environmentsreturns all environments -
POST /api/environmentscreates new environment -
DELETE /api/environments/{id}fails for default environment -
GET /api/containers?environment=testreturns only test environment containers - Migration from v0.3 config format to v0.4
- Environment selector dropdown interaction
- Switch between environments updates dashboard
- Create new environment via Settings page
- Deploy stack to specific environment
- Migration flow: upgrade from v0.3, verify default environment created
The following features are explicitly NOT included in v0.4 and deferred to future releases:
- Multiple Docker hosts per environment
- Load balancing across nodes
- Node health monitoring
- Distributed container orchestration
- Environment templates
- Environment cloning
- Cross-environment promotion workflows
- Environment-specific RBAC
- Audit logs per environment
-
Docker Host Security: Should we support TLS-secured Docker hosts in v0.4, or only plain TCP/Unix sockets?
- Recommendation: Support basic auth (TCP/Unix) in v0.4, TLS in v0.5
-
Environment Deletion: What happens to deployed containers when environment is deleted?
- Recommendation: Prevent deletion if containers exist; require manual cleanup first
-
Environment Limits: Should there be a max number of environments per organization?
- Recommendation: No hard limit in v0.4; add if performance issues arise
-
Wizard Defaults: Should wizard auto-detect local Docker daemon, or require user input?
-
Recommendation: Auto-detect
unix:///var/run/docker.sockif available, otherwise prompt
-
Recommendation: Auto-detect
v0.4 is considered complete when:
- ✅ Users can create multiple environments via UI
- ✅ Users can switch between environments using dropdown selector
- ✅ Dashboard/containers/stacks are filtered by active environment
- ✅ Each environment has independent connection strings configuration
- ✅ Each environment connects to a different Docker host
- ✅ Stack deployments are scoped to active environment
- ✅ v0.3 installations automatically migrate to v0.4 with default environment
- ✅ All unit/integration/E2E tests pass
- ✅ Documentation updated (README, CHANGELOG, release notes)
| Phase | Duration | Tasks |
|---|---|---|
| Design | 1 week | Finalize API contracts, UI mockups, database schema |
| Backend | 2 weeks | Implement EnvironmentService, ConfigStore changes, migration logic |
| Frontend | 1 week | Build EnvironmentSelector, Settings page, hook integration |
| Testing | 1 week | Unit, integration, E2E tests |
| Documentation | 3 days | Update all docs, write migration guide |
| QA & Bug Fixes | 1 week | User acceptance testing, polish |
Total: ~6 weeks (1.5 months)
Getting Started
Architecture
Configuration
Security
Setup Wizard
Development
Operations
CI/CD
Reference
- Roadmap
- API Reference
- Configuration Reference
- Manifest Schema
- Multi-Environment
- Stack Sources
- Plugin System
- Technical Specification
- Full Specification
Specifications
Release Notes