Skip to content

Commit 7fc76cc

Browse files
hemanandrclaude
andcommitted
feat: Implement ENV-15 - Installer conventions and standardized paths
- Add comprehensive installer documentation (installer-map.md, logging.md) - Implement PathService for centralized path management with Windows permissions - Update service name from ThingConnectPulse to ThingConnectPulseSvc - Change startup type from Manual to Automatic - Standardize directory structure: %ProgramData%\ThingConnect.Pulse\{config,versions,logs,data} - Enhance install script with directory creation and uninstall data preservation - Update all configuration files to use new directory structure - Add installation documentation links to README Closes ENV-15. Ready for Issue #24 (Inno Setup installer). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 7b47913 commit 7fc76cc

File tree

11 files changed

+744
-18
lines changed

11 files changed

+744
-18
lines changed

DEVELOPMENT_PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ You can effectively work on **up to 6 parallel worktrees** without conflicts:
144144
| Issue | Priority | Time | Description | Worktree |
145145
|-------|----------|------|-------------|----------|
146146
| #23 | P1 | 1d |**COMPLETE** - Windows service host wrapper | 1 |
147-
| ENV-15 | P1 | 1d | Installer conventions, paths | 1 |
147+
| ENV-15 | P1 | 1d | **COMPLETE** - Installer conventions, paths | 1 |
148148
| #24 | P1 | 4-6h | Inno Setup installer script | 1 |
149149

150150
### PHASE 8: Quality & Polish (Week 4)

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ dotnet run
4949
- **[Data Model](./docs/data-model.cs)** - Entity Framework Core entities
5050
- **[Rollup Algorithms](./docs/rollup-spec.md)** - Data aggregation specifications
5151

52+
## Installation & Deployment
53+
54+
- **[Installer Conventions](./docs/installer-map.md)** - Windows service installation specification
55+
- **[Logging Configuration](./docs/logging.md)** - Structured logging setup and policies
56+
5257
## API Endpoints
5358

5459
The server provides REST API endpoints for configuration management:

ThingConnect.Pulse.Server/Data/SeedData.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ public static void Initialize(PulseDbContext context)
106106
Id = "test-config-001",
107107
AppliedTs = now,
108108
FileHash = "abcd1234",
109-
FilePath = @"C:\ProgramData\ThingConnect.Pulse\config.yaml",
109+
FilePath = @"C:\ProgramData\ThingConnect.Pulse\config\config.yaml",
110110
Actor = "system",
111111
Note = "Initial test configuration"
112112
};

ThingConnect.Pulse.Server/Program.cs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ namespace ThingConnect.Pulse.Server;
1313

1414
public class Program
1515
{
16-
public static void Main(string[] args)
16+
public static async Task Main(string[] args)
1717
{
18+
// Initialize path service for directory management
19+
var pathService = new PathService();
20+
1821
// Configure Serilog for rolling file logging
1922
Log.Logger = new LoggerConfiguration()
2023
.MinimumLevel.Information()
@@ -23,8 +26,7 @@ public static void Main(string[] args)
2326
.Enrich.FromLogContext()
2427
.WriteTo.Console()
2528
.WriteTo.File(
26-
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
27-
"ThingConnect.Pulse", "logs", "pulse-.log"),
29+
Path.Combine(pathService.GetLogsDirectory(), "pulse-.log"),
2830
rollingInterval: RollingInterval.Day,
2931
retainedFileCountLimit: 30,
3032
shared: true)
@@ -52,6 +54,9 @@ public static void Main(string[] args)
5254
// Add HTTP client for probes
5355
builder.Services.AddHttpClient();
5456

57+
// Add path service
58+
builder.Services.AddSingleton<IPathService, PathService>();
59+
5560
// Add configuration services
5661
builder.Services.AddSingleton<ConfigParser>();
5762
builder.Services.AddScoped<IConfigurationService, ConfigurationService>();
@@ -82,6 +87,14 @@ public static void Main(string[] args)
8287

8388
WebApplication app = builder.Build();
8489

90+
// Ensure all required directories exist
91+
using (IServiceScope scope = app.Services.CreateScope())
92+
{
93+
IPathService pathSvc = scope.ServiceProvider.GetRequiredService<IPathService>();
94+
await pathSvc.EnsureDirectoriesExistAsync();
95+
Log.Information("Directory structure verified at {RootPath}", pathSvc.GetRootDirectory());
96+
}
97+
8598
// Initialize database with seed data in development
8699
if (app.Environment.IsDevelopment())
87100
{

ThingConnect.Pulse.Server/Services/ConfigurationService.cs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,13 @@ public sealed class ConfigurationService : IConfigurationService
1717
{
1818
private readonly PulseDbContext _context;
1919
private readonly ConfigParser _parser;
20-
private readonly string _versionsPath;
20+
private readonly IPathService _pathService;
2121

22-
public ConfigurationService(PulseDbContext context, ConfigParser parser)
22+
public ConfigurationService(PulseDbContext context, ConfigParser parser, IPathService pathService)
2323
{
2424
_context = context;
2525
_parser = parser;
26-
_versionsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
27-
"ThingConnect.Pulse", "versions");
26+
_pathService = pathService;
2827
}
2928

3029
public async Task<ApplyResultDto> ApplyConfigurationAsync(string yamlContent, string? actor = null, string? note = null)
@@ -66,9 +65,8 @@ public async Task<ApplyResultDto> ApplyConfigurationAsync(string yamlContent, st
6665
string versionId = GenerateVersionId();
6766
DateTimeOffset timestamp = DateTimeOffset.UtcNow;
6867
string fileName = $"{timestamp:yyyyMMdd_HHmmss}_{fileHash[..8]}.yaml";
69-
string filePath = Path.Combine(_versionsPath, fileName);
68+
string filePath = Path.Combine(_pathService.GetVersionsDirectory(), fileName);
7069

71-
Directory.CreateDirectory(_versionsPath);
7270
await File.WriteAllTextAsync(filePath, yamlContent);
7371

7472
var configVersion = new ConfigVersion
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
using System.Runtime.Versioning;
2+
#if NET8_0_OR_GREATER && WINDOWS
3+
using System.Security.AccessControl;
4+
using System.Security.Principal;
5+
#endif
6+
7+
namespace ThingConnect.Pulse.Server.Services;
8+
9+
public interface IPathService
10+
{
11+
string GetRootDirectory();
12+
string GetConfigDirectory();
13+
string GetConfigFilePath();
14+
string GetVersionsDirectory();
15+
string GetLogsDirectory();
16+
string GetDataDirectory();
17+
string GetDatabaseFilePath();
18+
Task EnsureDirectoriesExistAsync();
19+
}
20+
21+
public sealed class PathService : IPathService
22+
{
23+
private readonly string _rootDirectory;
24+
25+
public PathService()
26+
{
27+
_rootDirectory = Path.Combine(
28+
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
29+
"ThingConnect.Pulse");
30+
}
31+
32+
public string GetRootDirectory() => _rootDirectory;
33+
34+
public string GetConfigDirectory() => Path.Combine(_rootDirectory, "config");
35+
36+
public string GetConfigFilePath() => Path.Combine(GetConfigDirectory(), "config.yaml");
37+
38+
public string GetVersionsDirectory() => Path.Combine(_rootDirectory, "versions");
39+
40+
public string GetLogsDirectory() => Path.Combine(_rootDirectory, "logs");
41+
42+
public string GetDataDirectory() => Path.Combine(_rootDirectory, "data");
43+
44+
public string GetDatabaseFilePath() => Path.Combine(GetDataDirectory(), "pulse.db");
45+
46+
public async Task EnsureDirectoriesExistAsync()
47+
{
48+
var directories = new[]
49+
{
50+
GetRootDirectory(),
51+
GetConfigDirectory(),
52+
GetVersionsDirectory(),
53+
GetLogsDirectory(),
54+
GetDataDirectory()
55+
};
56+
57+
foreach (string directory in directories)
58+
{
59+
if (!Directory.Exists(directory))
60+
{
61+
Directory.CreateDirectory(directory);
62+
63+
// Set proper permissions for the service account (Windows only)
64+
if (OperatingSystem.IsWindows())
65+
{
66+
SetDirectoryPermissions(directory);
67+
}
68+
}
69+
}
70+
71+
// Create default config file if it doesn't exist
72+
string configFile = GetConfigFilePath();
73+
if (!File.Exists(configFile))
74+
{
75+
await CreateDefaultConfigFileAsync(configFile);
76+
}
77+
}
78+
79+
[SupportedOSPlatform("windows")]
80+
private static void SetDirectoryPermissions(string directory)
81+
{
82+
#if NET8_0_OR_GREATER && WINDOWS
83+
try
84+
{
85+
var directoryInfo = new DirectoryInfo(directory);
86+
DirectorySecurity security = directoryInfo.GetAccessControl();
87+
88+
// Grant full control to SYSTEM account
89+
var systemAccount = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
90+
var systemRule = new FileSystemAccessRule(
91+
systemAccount,
92+
FileSystemRights.FullControl,
93+
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
94+
PropagationFlags.None,
95+
AccessControlType.Allow);
96+
97+
// Grant read/modify to Administrators
98+
var adminAccount = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
99+
var adminRule = new FileSystemAccessRule(
100+
adminAccount,
101+
FileSystemRights.Modify,
102+
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
103+
PropagationFlags.None,
104+
AccessControlType.Allow);
105+
106+
security.SetAccessRule(systemRule);
107+
security.SetAccessRule(adminRule);
108+
109+
directoryInfo.SetAccessControl(security);
110+
}
111+
catch (Exception ex)
112+
{
113+
// Log warning but don't fail startup - permissions might work anyway
114+
Console.WriteLine($"Warning: Could not set directory permissions for {directory}: {ex.Message}");
115+
}
116+
#endif
117+
}
118+
119+
private static async Task CreateDefaultConfigFileAsync(string configFile)
120+
{
121+
const string defaultConfig = """
122+
# ThingConnect Pulse Configuration
123+
# This is the main configuration file for network monitoring
124+
#
125+
# For configuration syntax and examples, see:
126+
# https://github.com/MachDatum/ThingConnect.Pulse/blob/main/docs/config.schema.json
127+
128+
# Example configuration:
129+
# targets:
130+
# - name: "Router"
131+
# endpoints:
132+
# - host: "192.168.1.1"
133+
# type: "icmp"
134+
# - name: "Web Services"
135+
# endpoints:
136+
# - host: "www.example.com"
137+
# type: "http"
138+
# path: "/health"
139+
140+
# Empty configuration - add your monitoring targets above
141+
targets: []
142+
""";
143+
144+
await File.WriteAllTextAsync(configFile, defaultConfig);
145+
}
146+
}

ThingConnect.Pulse.Server/appsettings.Development.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
}
88
},
99
"ConnectionStrings": {
10-
"DefaultConnection": "Data Source=C:\\ProgramData\\ThingConnect.Pulse\\pulse.db"
10+
"DefaultConnection": "Data Source=C:\\ProgramData\\ThingConnect.Pulse\\data\\pulse.db"
1111
},
1212
"Kestrel": {
1313
"Endpoints": {
@@ -17,7 +17,7 @@
1717
}
1818
},
1919
"Pulse": {
20-
"ConfigPath": "C:\\ProgramData\\ThingConnect.Pulse\\config.yaml",
20+
"ConfigPath": "C:\\ProgramData\\ThingConnect.Pulse\\config\\config.yaml",
2121
"DataRetentionDays": 60,
2222
"ProbeSettings": {
2323
"MaxConcurrentProbes": 100,

ThingConnect.Pulse.Server/appsettings.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
},
88
"AllowedHosts": "*",
99
"ConnectionStrings": {
10-
"DefaultConnection": "Data Source=C:\\ProgramData\\ThingConnect.Pulse\\pulse.db"
10+
"DefaultConnection": "Data Source=C:\\ProgramData\\ThingConnect.Pulse\\data\\pulse.db"
1111
},
1212
"Pulse": {
13-
"ConfigPath": "C:\\ProgramData\\ThingConnect.Pulse\\config.yaml",
13+
"ConfigPath": "C:\\ProgramData\\ThingConnect.Pulse\\config\\config.yaml",
1414
"DataRetentionDays": 60,
1515
"ProbeSettings": {
1616
"MaxConcurrentProbes": 100,

0 commit comments

Comments
 (0)