diff --git a/.changeset/agent-port-allocation.md b/.changeset/agent-port-allocation.md new file mode 100644 index 0000000..813951e --- /dev/null +++ b/.changeset/agent-port-allocation.md @@ -0,0 +1,9 @@ +--- +'@opsen/agent': minor +--- + +feat(agent): add host port allocation, tmpfs merge, and MirrorState client policies + +- Compose role manages host port allocation from a configured `port_range`. Services declare container ports via `expose:`, and the agent allocates host ports bound to the client's `ingress_bind_address`. Port mappings are returned in the deploy response. +- Tmpfs handling merges client entries with global defaults instead of overwriting. +- Client policy files are now managed via MirrorState (from @opsen/docker-compose) instead of individual remote commands, ensuring stale policy files are cleaned up when clients are removed. diff --git a/CLAUDE.md b/CLAUDE.md index 17e531a..bca5a5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ This is a **library monorepo** — there is no CLI. All packages are published t @opsen/agent (standalone — Go binary + Pulumi installer) ``` -Inter-package dependencies use `file:` relative paths (not `workspace:*`) so external consumers can reference opsen packages via `file:` during development. +Inter-package dependencies use `workspace:^` protocol within the monorepo. External consumers can reference opsen packages via `file:` paths during development. ## Common Commands @@ -97,13 +97,24 @@ The root `tsconfig.json` uses TypeScript project references (`composite: true`). ## Code Conventions -- **ES Modules** throughout (`"type": "module"` in root package.json, `verbatimModuleSyntax` in tsconfig) +- **ES Modules** throughout — see ESM rules below - **Prettier**: single quotes, no semicolons, 120 print width - **Conventional Commits**: enforced by commitlint + husky (`feat:`, `fix:`, `refactor:`, etc.) - **Node.js >= 22**, **pnpm >= 10.12.1** (npm/yarn blocked) - TypeScript strict mode with all strict flags enabled - Tests use **Vitest** with globals enabled; unit tests are `*.test.ts`, e2e tests are `*.e2e.test.ts` +### ESM and Module Resolution + +The root `package.json` has `"type": "module"`. TypeScript uses `module: "nodenext"` / `moduleResolution: "node16"` (via `@tsconfig/node22`). + +**Do NOT add `"type": "module"` to sub-package `package.json` files.** Pulumi packages (`@pulumi/kubernetes`, `@pulumi/docker`, `@pulumi/azure-native`) lack proper ESM `exports` maps, so their deep subpath imports (e.g. `@pulumi/azure-native/network`, `@pulumi/kubernetes/types`) break under strict `nodenext` resolution when the consuming package has `type: "module"`. Only `@opsen/agent` and `@opsen/cert-renewer` have it because they don't use Pulumi deep imports. + +**Package setup** — every `package.json` under `packages/` MUST have: + +- `"main": "src/index.ts"` — for local development / workspace resolution +- `"publishConfig": { "main": "dist/index.js", "types": "dist/index.d.ts" }` — for npm consumers + ## Security - **No shell command injection** — never interpolate variables into `execSync()` strings. Use `execFileSync(cmd, args[])` with argument arrays instead. This applies to all CLI wrappers (`az`, `hcloud`, `docker`, `ssh`, `kubectl`, etc.) in `@opsen/testing` and e2e tests. diff --git a/packages/agent/go/cmd/opsen-agent/main.go b/packages/agent/go/cmd/opsen-agent/main.go index 708e3b4..0282ad9 100644 --- a/packages/agent/go/cmd/opsen-agent/main.go +++ b/packages/agent/go/cmd/opsen-agent/main.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" "syscall" + "time" "github.com/opsen/agent/internal/config" "github.com/opsen/agent/internal/server" @@ -46,6 +47,12 @@ func main() { defer dbHandler.Close() } + // Start compose policy reconciler if compose role is enabled + if ch := srv.ComposeHandler(); ch != nil { + reconciler := ch.Reconciler() + go reconciler.Run(10 * time.Second) + } + go func() { logger.Info("starting opsen-agent", "listen", cfg.Listen) if err := srv.ListenAndServeTLS(); err != nil { diff --git a/packages/agent/go/internal/config/agent.go b/packages/agent/go/internal/config/agent.go index ef1c2b6..668b7ee 100644 --- a/packages/agent/go/internal/config/agent.go +++ b/packages/agent/go/internal/config/agent.go @@ -35,6 +35,7 @@ type ComposeRoleConfig struct { ComposeBinary string `yaml:"compose_binary"` DeploymentsDir string `yaml:"deployments_dir"` NetworkPrefix string `yaml:"network_prefix"` + PortRange string `yaml:"port_range"` // Host port range for exposed container ports, e.g. "8000-8999" } type IngressRoleConfig struct { diff --git a/packages/agent/go/internal/roles/compose/compose.go b/packages/agent/go/internal/roles/compose/compose.go index 451e46d..4d194cd 100644 --- a/packages/agent/go/internal/roles/compose/compose.go +++ b/packages/agent/go/internal/roles/compose/compose.go @@ -48,6 +48,7 @@ type ComposeService struct { StopSignal string `yaml:"stop_signal,omitempty"` StopGracePeriod string `yaml:"stop_grace_period,omitempty"` Logging any `yaml:"logging,omitempty"` + Expose []string `yaml:"expose,omitempty"` ExtraHosts []string `yaml:"extra_hosts,omitempty"` Entrypoint any `yaml:"entrypoint,omitempty"` WorkingDir string `yaml:"working_dir,omitempty"` @@ -172,12 +173,41 @@ func validateCompose(compose *ComposeFile, cfg *config.AgentConfig, policy *conf } // hardenCompose injects security defaults into all services. -func hardenCompose(compose *ComposeFile, cfg *config.AgentConfig, client *config.ClientPolicy) []string { +// portMappings are injected as host port bindings (from expose → allocated ports). +func hardenCompose(compose *ComposeFile, cfg *config.AgentConfig, client *config.ClientPolicy, portMappings []PortMapping) []string { var modifications []string hardening := cfg.GlobalHardening networkName := fmt.Sprintf("opsen-%s-internal", client.Client) + // Build a lookup of allocated ports by service name + bindAddr := "" + if client.Compose != nil { + bindAddr = client.Compose.Network.IngressBindAddress + } + svcPorts := make(map[string][]PortMapping) + for _, m := range portMappings { + svcPorts[m.Service] = append(svcPorts[m.Service], m) + } + for name, svc := range compose.Services { + // Strip client-specified ports — the agent owns host port bindings + if len(svc.Ports) > 0 { + svc.Ports = nil + modifications = append(modifications, fmt.Sprintf("%s: removed client ports (agent manages port allocation)", name)) + } + + // Inject allocated port bindings from expose entries + if mappings, ok := svcPorts[name]; ok { + for _, m := range mappings { + binding := fmt.Sprintf("%s:%d:%s", bindAddr, m.HostPort, m.ContainerPort) + svc.Ports = append(svc.Ports, binding) + } + modifications = append(modifications, fmt.Sprintf("%s: allocated host ports from expose", name)) + } + + // Clear expose — it has been converted to port bindings + svc.Expose = nil + if hardening.NoNewPrivileges { if !containsString(svc.SecurityOpt, "no-new-privileges:true") { svc.SecurityOpt = append(svc.SecurityOpt, "no-new-privileges:true") @@ -208,16 +238,27 @@ func hardenCompose(compose *ComposeFile, cfg *config.AgentConfig, client *config modifications = append(modifications, fmt.Sprintf("%s: set read_only true", name)) } - if len(hardening.DefaultTmpfs) > 0 { - var tmpfs []string + if len(hardening.DefaultTmpfs) > 0 || svc.Tmpfs != nil { + defaultPaths := make(map[string]bool) + var merged []string + for _, t := range hardening.DefaultTmpfs { entry := t.Path if t.Options != "" { entry += ":" + t.Options } - tmpfs = append(tmpfs, entry) + defaultPaths[t.Path] = true + merged = append(merged, entry) } - svc.Tmpfs = tmpfs + + for _, entry := range parseTmpfsEntries(svc.Tmpfs) { + path := strings.SplitN(entry, ":", 2)[0] + if !defaultPaths[path] { + merged = append(merged, entry) + } + } + + svc.Tmpfs = merged modifications = append(modifications, fmt.Sprintf("%s: set tmpfs", name)) } @@ -351,6 +392,29 @@ func extractTag(image string) string { return parts[1] } +// parseTmpfsEntries extracts tmpfs mount strings from the any-typed Tmpfs field. +// Docker Compose accepts tmpfs as a single string or a list of strings. +func parseTmpfsEntries(v any) []string { + if v == nil { + return nil + } + switch val := v.(type) { + case string: + return []string{val} + case []string: + return val + case []any: + var entries []string + for _, item := range val { + if s, ok := item.(string); ok { + entries = append(entries, s) + } + } + return entries + } + return nil +} + func parseMemoryMb(mem string) int { if mem == "" { return 0 diff --git a/packages/agent/go/internal/roles/compose/compose_test.go b/packages/agent/go/internal/roles/compose/compose_test.go new file mode 100644 index 0000000..16e0ff8 --- /dev/null +++ b/packages/agent/go/internal/roles/compose/compose_test.go @@ -0,0 +1,273 @@ +package compose + +import ( + "strings" + "testing" + + "github.com/opsen/agent/internal/config" +) + +func minimalConfig() *config.AgentConfig { + return &config.AgentConfig{ + GlobalHardening: config.GlobalHardening{}, + } +} + +func minimalClient(name string) *config.ClientPolicy { + return &config.ClientPolicy{ + Client: name, + Compose: &config.ComposePolicy{}, + } +} + +func TestHardenCompose_StripsClientPorts(t *testing.T) { + compose := &ComposeFile{ + Services: map[string]*ComposeService{ + "web": {Ports: []string{"0.0.0.0:80:80", "443:443"}}, + }, + } + + mods := hardenCompose(compose, minimalConfig(), minimalClient("c1"), nil) + + if len(compose.Services["web"].Ports) != 0 { + t.Errorf("expected ports to be stripped, got %v", compose.Services["web"].Ports) + } + + found := false + for _, m := range mods { + if strings.Contains(m, "removed client ports") { + found = true + } + } + if !found { + t.Error("expected modification about removed client ports") + } +} + +func TestHardenCompose_InjectsAllocatedPorts(t *testing.T) { + compose := &ComposeFile{ + Services: map[string]*ComposeService{ + "web": {Expose: []string{"80"}}, + "api": {Expose: []string{"3000"}}, + }, + } + + client := minimalClient("c1") + client.Compose.Network.IngressBindAddress = "10.0.1.2" + + portMappings := []PortMapping{ + {HostPort: 8000, ContainerPort: "80", Service: "web"}, + {HostPort: 8001, ContainerPort: "3000", Service: "api"}, + } + + hardenCompose(compose, minimalConfig(), client, portMappings) + + // Check web service got its port binding + webPorts := compose.Services["web"].Ports + if len(webPorts) != 1 { + t.Fatalf("expected 1 port for web, got %d", len(webPorts)) + } + if webPorts[0] != "10.0.1.2:8000:80" { + t.Errorf("expected 10.0.1.2:8000:80, got %s", webPorts[0]) + } + + // Check api service got its port binding + apiPorts := compose.Services["api"].Ports + if len(apiPorts) != 1 { + t.Fatalf("expected 1 port for api, got %d", len(apiPorts)) + } + if apiPorts[0] != "10.0.1.2:8001:3000" { + t.Errorf("expected 10.0.1.2:8001:3000, got %s", apiPorts[0]) + } + + // Expose should be cleared + if compose.Services["web"].Expose != nil { + t.Error("expected expose to be cleared on web") + } + if compose.Services["api"].Expose != nil { + t.Error("expected expose to be cleared on api") + } +} + +func TestHardenCompose_PortsReplacedByAllocated(t *testing.T) { + compose := &ComposeFile{ + Services: map[string]*ComposeService{ + "web": { + Ports: []string{"0.0.0.0:80:80"}, + Expose: []string{"80"}, + }, + }, + } + + client := minimalClient("c1") + client.Compose.Network.IngressBindAddress = "10.0.1.5" + + portMappings := []PortMapping{ + {HostPort: 8042, ContainerPort: "80", Service: "web"}, + } + + hardenCompose(compose, minimalConfig(), client, portMappings) + + ports := compose.Services["web"].Ports + if len(ports) != 1 || ports[0] != "10.0.1.5:8042:80" { + t.Errorf("expected client ports replaced by allocated, got %v", ports) + } +} + +func TestHardenCompose_EmptyBindAddress(t *testing.T) { + compose := &ComposeFile{ + Services: map[string]*ComposeService{ + "web": {}, + }, + } + + portMappings := []PortMapping{ + {HostPort: 8000, ContainerPort: "80", Service: "web"}, + } + + hardenCompose(compose, minimalConfig(), minimalClient("c1"), portMappings) + + ports := compose.Services["web"].Ports + if len(ports) != 1 || ports[0] != ":8000:80" { + t.Errorf("expected :8000:80 with empty bind address, got %v", ports) + } +} + +func TestHardenCompose_NoPortMappings(t *testing.T) { + compose := &ComposeFile{ + Services: map[string]*ComposeService{ + "worker": {Image: "busybox"}, + }, + } + + hardenCompose(compose, minimalConfig(), minimalClient("c1"), nil) + + if len(compose.Services["worker"].Ports) != 0 { + t.Errorf("expected no ports on worker, got %v", compose.Services["worker"].Ports) + } +} + +func TestHardenCompose_TmpfsMerge(t *testing.T) { + compose := &ComposeFile{ + Services: map[string]*ComposeService{ + "web": { + Tmpfs: []string{"/var/cache/nginx"}, + }, + }, + } + + cfg := minimalConfig() + cfg.GlobalHardening.DefaultTmpfs = []config.TmpfsMount{ + {Path: "/tmp", Options: "noexec,nosuid,size=64m"}, + {Path: "/run", Options: "size=16m"}, + } + + hardenCompose(compose, cfg, minimalClient("c1"), nil) + + tmpfs := parseTmpfsEntries(compose.Services["web"].Tmpfs) + if len(tmpfs) != 3 { + t.Fatalf("expected 3 tmpfs entries, got %d: %v", len(tmpfs), tmpfs) + } + + paths := make(map[string]bool) + for _, entry := range tmpfs { + path := strings.SplitN(entry, ":", 2)[0] + paths[path] = true + } + + for _, expected := range []string{"/tmp", "/run", "/var/cache/nginx"} { + if !paths[expected] { + t.Errorf("missing tmpfs path: %s", expected) + } + } +} + +func TestHardenCompose_TmpfsDefaultOverridesClient(t *testing.T) { + // When client specifies /tmp and defaults also specify /tmp, + // the default should win + compose := &ComposeFile{ + Services: map[string]*ComposeService{ + "web": { + Tmpfs: []string{"/tmp:size=999m"}, + }, + }, + } + + cfg := minimalConfig() + cfg.GlobalHardening.DefaultTmpfs = []config.TmpfsMount{ + {Path: "/tmp", Options: "noexec,nosuid,size=64m"}, + } + + hardenCompose(compose, cfg, minimalClient("c1"), nil) + + tmpfs := parseTmpfsEntries(compose.Services["web"].Tmpfs) + if len(tmpfs) != 1 { + t.Fatalf("expected 1 tmpfs entry (deduped), got %d: %v", len(tmpfs), tmpfs) + } + if tmpfs[0] != "/tmp:noexec,nosuid,size=64m" { + t.Errorf("expected default /tmp options to win, got %s", tmpfs[0]) + } +} + +func TestHardenCompose_TmpfsNoDefaults(t *testing.T) { + compose := &ComposeFile{ + Services: map[string]*ComposeService{ + "web": { + Tmpfs: []string{"/var/cache/nginx"}, + }, + }, + } + + // No default tmpfs configured + hardenCompose(compose, minimalConfig(), minimalClient("c1"), nil) + + tmpfs := parseTmpfsEntries(compose.Services["web"].Tmpfs) + if len(tmpfs) != 1 || tmpfs[0] != "/var/cache/nginx" { + t.Errorf("expected client tmpfs preserved when no defaults, got %v", tmpfs) + } +} + +func TestHardenCompose_TmpfsStringType(t *testing.T) { + // Docker Compose accepts tmpfs as a single string + compose := &ComposeFile{ + Services: map[string]*ComposeService{ + "web": { + Tmpfs: "/var/cache", + }, + }, + } + + cfg := minimalConfig() + cfg.GlobalHardening.DefaultTmpfs = []config.TmpfsMount{ + {Path: "/tmp"}, + } + + hardenCompose(compose, cfg, minimalClient("c1"), nil) + + tmpfs := parseTmpfsEntries(compose.Services["web"].Tmpfs) + if len(tmpfs) != 2 { + t.Fatalf("expected 2 tmpfs entries, got %d: %v", len(tmpfs), tmpfs) + } +} + +func TestParseTmpfsEntries(t *testing.T) { + tests := []struct { + name string + input any + want int + }{ + {"nil", nil, 0}, + {"string", "/tmp", 1}, + {"string slice", []string{"/tmp", "/run"}, 2}, + {"any slice", []any{"/tmp", "/run"}, 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseTmpfsEntries(tt.input) + if len(got) != tt.want { + t.Errorf("parseTmpfsEntries(%v) = %d entries, want %d", tt.input, len(got), tt.want) + } + }) + } +} diff --git a/packages/agent/go/internal/roles/compose/handler.go b/packages/agent/go/internal/roles/compose/handler.go index 80974be..7ac4c3a 100644 --- a/packages/agent/go/internal/roles/compose/handler.go +++ b/packages/agent/go/internal/roles/compose/handler.go @@ -18,6 +18,7 @@ type Handler struct { cfg *config.AgentConfig clientStore *config.ClientStore tracker *ResourceTracker + ports *PortAllocator logger *slog.Logger } @@ -28,7 +29,24 @@ func NewHandler(cfg *config.AgentConfig, clientStore *config.ClientStore, logger logger.Warn("failed to load resource tracker, starting fresh", "error", err) tracker = &ResourceTracker{path: trackerPath, Clients: make(map[string]*ClientResources), logger: logger} } - return &Handler{cfg: cfg, clientStore: clientStore, tracker: tracker, logger: logger} + + var ports *PortAllocator + if cfg.Roles.Compose.PortRange != "" { + portsPath := filepath.Join(cfg.Roles.Compose.DeploymentsDir, "port-state.json") + ports, err = NewPortAllocator(portsPath, cfg.Roles.Compose.PortRange, logger) + if err != nil { + logger.Warn("failed to load port allocator, starting fresh", "error", err) + min, max, _ := parsePortRange(cfg.Roles.Compose.PortRange) + ports = &PortAllocator{path: portsPath, logger: logger, rangeMin: min, rangeMax: max, Clients: make(map[string]map[string]*ProjectPorts)} + } + } + + return &Handler{cfg: cfg, clientStore: clientStore, tracker: tracker, ports: ports, logger: logger} +} + +// Reconciler returns a Reconciler that watches for policy changes and redeploys affected projects. +func (h *Handler) Reconciler() *Reconciler { + return NewReconciler(h.cfg, h.clientStore, h.tracker, h.ports, h.logger) } // DeployRequest represents a file-based project deployment. @@ -39,10 +57,11 @@ type DeployRequest struct { } type DeployResponse struct { - Status string `json:"status"` - Project string `json:"project"` - Services []string `json:"services,omitempty"` - Modified []string `json:"policy_modifications,omitempty"` + Status string `json:"status"` + Project string `json:"project"` + Services []string `json:"services,omitempty"` + Modified []string `json:"policy_modifications,omitempty"` + Ports map[string]map[string]int `json:"ports,omitempty"` // service -> container_port -> host_port } func (h *Handler) Deploy(w http.ResponseWriter, r *http.Request) { @@ -97,8 +116,24 @@ func (h *Handler) Deploy(w http.ResponseWriter, r *http.Request) { return } + // Allocate host ports for exposed container ports + var portMappings []PortMapping + exposeRequests := extractExposeEntries(composeFile) + if len(exposeRequests) > 0 { + if h.ports == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "services use expose but no port_range configured in agent"}) + return + } + var err error + portMappings, err = h.ports.Allocate(client.Client, projectSlug, exposeRequests) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("port allocation failed: %v", err)}) + return + } + } + // Apply hardening and namespacing - modifications := hardenCompose(composeFile, h.cfg, client) + modifications := hardenCompose(composeFile, h.cfg, client, portMappings) // Write all project files projectName := fmt.Sprintf("opsen-%s-%s", client.Client, projectSlug) @@ -156,15 +191,29 @@ func (h *Handler) Deploy(w http.ResponseWriter, r *http.Request) { return } - // Track resources + // Track resources with current policy hash + requestedResources.PolicyHash = policyHash(client, h.cfg) h.tracker.Set(client.Client, projectSlug, requestedResources) + // Build port mapping response: service -> container_port -> host_port + var portsResponse map[string]map[string]int + if len(portMappings) > 0 { + portsResponse = make(map[string]map[string]int) + for _, m := range portMappings { + if portsResponse[m.Service] == nil { + portsResponse[m.Service] = make(map[string]int) + } + portsResponse[m.Service][m.ContainerPort] = m.HostPort + } + } + h.logger.Info("deployed", "client", client.Client, "project", projectSlug, "services", services) writeJSON(w, http.StatusOK, DeployResponse{ Status: "deployed", Project: projectName, Services: services, Modified: modifications, + Ports: portsResponse, }) } @@ -193,6 +242,9 @@ func (h *Handler) Destroy(w http.ResponseWriter, r *http.Request) { os.RemoveAll(projectDir) h.tracker.Remove(client.Client, projectSlug) + if h.ports != nil { + h.ports.Release(client.Client, projectSlug) + } h.logger.Info("destroyed", "client", client.Client, "project", projectSlug) writeJSON(w, http.StatusOK, map[string]string{"status": "destroyed", "project": projectName}) @@ -259,17 +311,12 @@ func (h *Handler) statusAll(w http.ResponseWriter, client *config.ClientPolicy) } func (h *Handler) composeUp(project, composePath string) ([]string, error) { - composeBin := h.cfg.Roles.Compose.ComposeBinary - parts := strings.Fields(composeBin) - - args := append(parts[1:], "-p", project, "-f", composePath, "up", "-d", "--remove-orphans") - cmd := exec.Command(parts[0], args...) - cmd.Dir = filepath.Dir(composePath) - output, err := cmd.CombinedOutput() - if err != nil { - return nil, fmt.Errorf("%s: %s", err, string(output)) + if err := composeUp(h.cfg.Roles.Compose.ComposeBinary, project, composePath); err != nil { + return nil, err } + composeBin := h.cfg.Roles.Compose.ComposeBinary + parts := strings.Fields(composeBin) psArgs := append(parts[1:], "-p", project, "-f", composePath, "ps", "--services") psCmd := exec.Command(parts[0], psArgs...) psCmd.Dir = filepath.Dir(composePath) @@ -285,6 +332,19 @@ func (h *Handler) composeUp(project, composePath string) ([]string, error) { return services, nil } +// composeUp runs docker compose up for a project. Used by both the handler and reconciler. +func composeUp(composeBin, project, composePath string) error { + parts := strings.Fields(composeBin) + args := append(parts[1:], "-p", project, "-f", composePath, "up", "-d", "--remove-orphans") + cmd := exec.Command(parts[0], args...) + cmd.Dir = filepath.Dir(composePath) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s: %s", err, string(output)) + } + return nil +} + func (h *Handler) composeDown(project, composePath string) error { composeBin := h.cfg.Roles.Compose.ComposeBinary parts := strings.Fields(composeBin) diff --git a/packages/agent/go/internal/roles/compose/ports.go b/packages/agent/go/internal/roles/compose/ports.go new file mode 100644 index 0000000..0ed71fb --- /dev/null +++ b/packages/agent/go/internal/roles/compose/ports.go @@ -0,0 +1,236 @@ +package compose + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "strconv" + "strings" + "sync" +) + +// PortMapping describes a single allocated port binding. +type PortMapping struct { + HostPort int `json:"host_port"` + ContainerPort string `json:"container_port"` + Service string `json:"service"` +} + +// ProjectPorts holds all port allocations for a single project. +type ProjectPorts struct { + Ports []PortMapping `json:"ports"` +} + +// PortAllocator manages host port assignments from a configured range. +// Allocations are persisted to survive agent restarts. +type PortAllocator struct { + mu sync.Mutex + path string + logger *slog.Logger + rangeMin int + rangeMax int + // Clients maps client name -> project slug -> allocated ports + Clients map[string]map[string]*ProjectPorts `json:"clients"` +} + +func NewPortAllocator(path string, portRange string, logger *slog.Logger) (*PortAllocator, error) { + min, max, err := parsePortRange(portRange) + if err != nil { + return nil, fmt.Errorf("invalid port range: %w", err) + } + + pa := &PortAllocator{ + path: path, + logger: logger, + rangeMin: min, + rangeMax: max, + Clients: make(map[string]map[string]*ProjectPorts), + } + + data, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("reading port state: %w", err) + } + } else { + if err := json.Unmarshal(data, pa); err != nil { + return nil, fmt.Errorf("parsing port state: %w", err) + } + } + + return pa, nil +} + +// Allocate assigns host ports for exposed container ports in a project. +// If the project already has allocations and the exposed ports haven't changed, +// existing allocations are reused. Otherwise, old ports are released and new ones allocated. +func (pa *PortAllocator) Allocate(clientName, projectSlug string, requests []ServicePort) ([]PortMapping, error) { + pa.mu.Lock() + defer pa.mu.Unlock() + + used := pa.usedPorts(clientName, projectSlug) + + // Check if existing allocations can be reused + existing := pa.getProject(clientName, projectSlug) + if existing != nil && portsMatch(existing.Ports, requests) { + return existing.Ports, nil + } + + var mappings []PortMapping + for _, req := range requests { + port, err := pa.findFreePort(used) + if err != nil { + return nil, fmt.Errorf("service %s port %s: %w", req.Service, req.ContainerPort, err) + } + used[port] = true + mappings = append(mappings, PortMapping{ + HostPort: port, + ContainerPort: req.ContainerPort, + Service: req.Service, + }) + } + + if pa.Clients[clientName] == nil { + pa.Clients[clientName] = make(map[string]*ProjectPorts) + } + pa.Clients[clientName][projectSlug] = &ProjectPorts{Ports: mappings} + pa.save() + + return mappings, nil +} + +// Release frees all port allocations for a project. +func (pa *PortAllocator) Release(clientName, projectSlug string) { + pa.mu.Lock() + defer pa.mu.Unlock() + + client := pa.Clients[clientName] + if client == nil { + return + } + delete(client, projectSlug) + if len(client) == 0 { + delete(pa.Clients, clientName) + } + pa.save() +} + +// GetProject returns the port allocations for a project. +func (pa *PortAllocator) GetProject(clientName, projectSlug string) *ProjectPorts { + pa.mu.Lock() + defer pa.mu.Unlock() + return pa.getProject(clientName, projectSlug) +} + +func (pa *PortAllocator) getProject(clientName, projectSlug string) *ProjectPorts { + client := pa.Clients[clientName] + if client == nil { + return nil + } + return client[projectSlug] +} + +// ServicePort is a request to allocate a host port for a service's container port. +type ServicePort struct { + Service string + ContainerPort string +} + +// usedPorts returns all host ports currently allocated, excluding the given project +// (since it's being replaced). +func (pa *PortAllocator) usedPorts(excludeClient, excludeProject string) map[int]bool { + used := make(map[int]bool) + for client, projects := range pa.Clients { + for project, pp := range projects { + if client == excludeClient && project == excludeProject { + continue + } + for _, m := range pp.Ports { + used[m.HostPort] = true + } + } + } + return used +} + +func (pa *PortAllocator) findFreePort(used map[int]bool) (int, error) { + for port := pa.rangeMin; port <= pa.rangeMax; port++ { + if !used[port] { + return port, nil + } + } + return 0, fmt.Errorf("no free ports in range %d-%d", pa.rangeMin, pa.rangeMax) +} + +func (pa *PortAllocator) save() { + data, err := json.MarshalIndent(pa, "", " ") + if err != nil { + pa.logger.Error("failed to serialize port state", "error", err) + return + } + if err := os.WriteFile(pa.path, data, 0640); err != nil { + pa.logger.Error("failed to write port state", "error", err) + } +} + +// portsMatch checks if existing allocations cover exactly the requested ports. +func portsMatch(existing []PortMapping, requests []ServicePort) bool { + if len(existing) != len(requests) { + return false + } + type key struct{ svc, port string } + have := make(map[key]bool, len(existing)) + for _, m := range existing { + have[key{m.Service, m.ContainerPort}] = true + } + for _, r := range requests { + if !have[key{r.Service, r.ContainerPort}] { + return false + } + } + return true +} + +func parsePortRange(s string) (int, int, error) { + if s == "" { + return 0, 0, fmt.Errorf("empty port range") + } + parts := strings.SplitN(s, "-", 2) + if len(parts) != 2 { + return 0, 0, fmt.Errorf("expected format: min-max (e.g. 8000-8999)") + } + min, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil { + return 0, 0, fmt.Errorf("invalid min port: %w", err) + } + max, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil { + return 0, 0, fmt.Errorf("invalid max port: %w", err) + } + if min > max { + return 0, 0, fmt.Errorf("min port %d > max port %d", min, max) + } + if min < 1 || max > 65535 { + return 0, 0, fmt.Errorf("ports must be in range 1-65535") + } + return min, max, nil +} + +// extractExposeEntries collects all exposed ports from a compose file. +// Returns a list of ServicePort requests for port allocation. +func extractExposeEntries(compose *ComposeFile) []ServicePort { + var requests []ServicePort + for name, svc := range compose.Services { + for _, entry := range svc.Expose { + // expose entries can be "80", "80/tcp", "8080-8090" etc. + // We only handle simple port numbers. + port := strings.SplitN(entry, "/", 2)[0] + requests = append(requests, ServicePort{ + Service: name, + ContainerPort: port, + }) + } + } + return requests +} diff --git a/packages/agent/go/internal/roles/compose/ports_test.go b/packages/agent/go/internal/roles/compose/ports_test.go new file mode 100644 index 0000000..fbb48f0 --- /dev/null +++ b/packages/agent/go/internal/roles/compose/ports_test.go @@ -0,0 +1,334 @@ +package compose + +import ( + "encoding/json" + "log/slog" + "os" + "path/filepath" + "testing" +) + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) +} + +func tempPortAllocator(t *testing.T, portRange string) *PortAllocator { + t.Helper() + path := filepath.Join(t.TempDir(), "port-state.json") + pa, err := NewPortAllocator(path, portRange, testLogger()) + if err != nil { + t.Fatalf("NewPortAllocator: %v", err) + } + return pa +} + +func TestPortAllocator_AllocateBasic(t *testing.T) { + pa := tempPortAllocator(t, "8000-8999") + + requests := []ServicePort{ + {Service: "web", ContainerPort: "80"}, + {Service: "api", ContainerPort: "3000"}, + } + + mappings, err := pa.Allocate("client1", "project1", requests) + if err != nil { + t.Fatalf("Allocate: %v", err) + } + + if len(mappings) != 2 { + t.Fatalf("expected 2 mappings, got %d", len(mappings)) + } + + if mappings[0].HostPort != 8000 { + t.Errorf("expected first port 8000, got %d", mappings[0].HostPort) + } + if mappings[0].Service != "web" || mappings[0].ContainerPort != "80" { + t.Errorf("unexpected mapping[0]: %+v", mappings[0]) + } + if mappings[1].HostPort != 8001 { + t.Errorf("expected second port 8001, got %d", mappings[1].HostPort) + } +} + +func TestPortAllocator_ReusesExisting(t *testing.T) { + pa := tempPortAllocator(t, "8000-8999") + + requests := []ServicePort{{Service: "web", ContainerPort: "80"}} + + first, err := pa.Allocate("client1", "project1", requests) + if err != nil { + t.Fatalf("first Allocate: %v", err) + } + + second, err := pa.Allocate("client1", "project1", requests) + if err != nil { + t.Fatalf("second Allocate: %v", err) + } + + if first[0].HostPort != second[0].HostPort { + t.Errorf("expected reuse: first=%d, second=%d", first[0].HostPort, second[0].HostPort) + } +} + +func TestPortAllocator_ReallocatesOnChange(t *testing.T) { + pa := tempPortAllocator(t, "8000-8999") + + first, err := pa.Allocate("client1", "project1", []ServicePort{ + {Service: "web", ContainerPort: "80"}, + }) + if err != nil { + t.Fatalf("first Allocate: %v", err) + } + + // Change exposed ports — should reallocate + second, err := pa.Allocate("client1", "project1", []ServicePort{ + {Service: "web", ContainerPort: "8080"}, + }) + if err != nil { + t.Fatalf("second Allocate: %v", err) + } + + if second[0].ContainerPort != "8080" { + t.Errorf("expected container port 8080, got %s", second[0].ContainerPort) + } + // Should get port 8000 again since old allocation is replaced + if second[0].HostPort != first[0].HostPort { + t.Errorf("expected port reuse after replace: first=%d, second=%d", first[0].HostPort, second[0].HostPort) + } +} + +func TestPortAllocator_IsolatesBetweenClients(t *testing.T) { + pa := tempPortAllocator(t, "8000-8001") + + _, err := pa.Allocate("client1", "proj", []ServicePort{{Service: "web", ContainerPort: "80"}}) + if err != nil { + t.Fatalf("client1 Allocate: %v", err) + } + + mappings, err := pa.Allocate("client2", "proj", []ServicePort{{Service: "web", ContainerPort: "80"}}) + if err != nil { + t.Fatalf("client2 Allocate: %v", err) + } + + if mappings[0].HostPort != 8001 { + t.Errorf("expected client2 to get 8001, got %d", mappings[0].HostPort) + } +} + +func TestPortAllocator_RangeExhaustion(t *testing.T) { + pa := tempPortAllocator(t, "8000-8001") + + _, err := pa.Allocate("c1", "p1", []ServicePort{{Service: "a", ContainerPort: "80"}}) + if err != nil { + t.Fatalf("first: %v", err) + } + _, err = pa.Allocate("c1", "p2", []ServicePort{{Service: "a", ContainerPort: "80"}}) + if err != nil { + t.Fatalf("second: %v", err) + } + + _, err = pa.Allocate("c1", "p3", []ServicePort{{Service: "a", ContainerPort: "80"}}) + if err == nil { + t.Fatal("expected error on range exhaustion") + } +} + +func TestPortAllocator_Release(t *testing.T) { + pa := tempPortAllocator(t, "8000-8000") // single port + + _, err := pa.Allocate("c1", "p1", []ServicePort{{Service: "web", ContainerPort: "80"}}) + if err != nil { + t.Fatalf("Allocate: %v", err) + } + + // Range is exhausted + _, err = pa.Allocate("c1", "p2", []ServicePort{{Service: "web", ContainerPort: "80"}}) + if err == nil { + t.Fatal("expected exhaustion error") + } + + // Release frees the port + pa.Release("c1", "p1") + + mappings, err := pa.Allocate("c1", "p2", []ServicePort{{Service: "web", ContainerPort: "80"}}) + if err != nil { + t.Fatalf("Allocate after release: %v", err) + } + if mappings[0].HostPort != 8000 { + t.Errorf("expected port 8000 after release, got %d", mappings[0].HostPort) + } +} + +func TestPortAllocator_Persistence(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "port-state.json") + + // Allocate with first instance + pa1, err := NewPortAllocator(path, "8000-8999", testLogger()) + if err != nil { + t.Fatalf("NewPortAllocator: %v", err) + } + _, err = pa1.Allocate("c1", "p1", []ServicePort{{Service: "web", ContainerPort: "80"}}) + if err != nil { + t.Fatalf("Allocate: %v", err) + } + + // Load second instance from same file + pa2, err := NewPortAllocator(path, "8000-8999", testLogger()) + if err != nil { + t.Fatalf("NewPortAllocator reload: %v", err) + } + + pp := pa2.GetProject("c1", "p1") + if pp == nil { + t.Fatal("expected project ports to be persisted") + } + if len(pp.Ports) != 1 || pp.Ports[0].HostPort != 8000 { + t.Errorf("unexpected persisted ports: %+v", pp.Ports) + } + + // New allocation should get next port (8000 is taken) + mappings, err := pa2.Allocate("c1", "p2", []ServicePort{{Service: "api", ContainerPort: "3000"}}) + if err != nil { + t.Fatalf("Allocate on reloaded: %v", err) + } + if mappings[0].HostPort != 8001 { + t.Errorf("expected 8001 on reloaded allocator, got %d", mappings[0].HostPort) + } +} + +func TestPortAllocator_PersistenceFileFormat(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "port-state.json") + + pa, err := NewPortAllocator(path, "8000-8999", testLogger()) + if err != nil { + t.Fatalf("NewPortAllocator: %v", err) + } + _, err = pa.Allocate("deployer", "myapp", []ServicePort{{Service: "web", ContainerPort: "80"}}) + if err != nil { + t.Fatalf("Allocate: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + var parsed map[string]any + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("JSON parse: %v", err) + } + + clients, ok := parsed["clients"].(map[string]any) + if !ok { + t.Fatal("expected clients key in persisted JSON") + } + deployer, ok := clients["deployer"].(map[string]any) + if !ok { + t.Fatal("expected deployer client in persisted JSON") + } + if _, ok := deployer["myapp"]; !ok { + t.Fatal("expected myapp project in persisted JSON") + } +} + +func TestParsePortRange(t *testing.T) { + tests := []struct { + input string + wantMin int + wantMax int + wantErr bool + }{ + {"8000-8999", 8000, 8999, false}, + {"80-80", 80, 80, false}, + {"", 0, 0, true}, + {"8000", 0, 0, true}, + {"9000-8000", 0, 0, true}, // min > max + {"0-100", 0, 0, true}, // port 0 invalid + {"abc-def", 0, 0, true}, // non-numeric + {"1-65535", 1, 65535, false}, // full range + {"1-65536", 0, 0, true}, // exceeds max + } + + for _, tt := range tests { + min, max, err := parsePortRange(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("parsePortRange(%q): expected error", tt.input) + } + continue + } + if err != nil { + t.Errorf("parsePortRange(%q): unexpected error: %v", tt.input, err) + continue + } + if min != tt.wantMin || max != tt.wantMax { + t.Errorf("parsePortRange(%q) = (%d, %d), want (%d, %d)", tt.input, min, max, tt.wantMin, tt.wantMax) + } + } +} + +func TestExtractExposeEntries(t *testing.T) { + compose := &ComposeFile{ + Services: map[string]*ComposeService{ + "web": {Expose: []string{"80", "443"}}, + "api": {Expose: []string{"3000/tcp"}}, + "db": {}, // no expose + }, + } + + entries := extractExposeEntries(compose) + + // Collect into a map for order-independent assertions + type key struct{ svc, port string } + got := make(map[key]bool) + for _, e := range entries { + got[key{e.Service, e.ContainerPort}] = true + } + + expected := []key{ + {"web", "80"}, + {"web", "443"}, + {"api", "3000"}, + } + + if len(entries) != len(expected) { + t.Fatalf("expected %d entries, got %d", len(expected), len(entries)) + } + for _, e := range expected { + if !got[e] { + t.Errorf("missing entry: %+v", e) + } + } +} + +func TestPortsMatch(t *testing.T) { + existing := []PortMapping{ + {HostPort: 8000, ContainerPort: "80", Service: "web"}, + {HostPort: 8001, ContainerPort: "3000", Service: "api"}, + } + + // Same requests — should match + if !portsMatch(existing, []ServicePort{ + {Service: "web", ContainerPort: "80"}, + {Service: "api", ContainerPort: "3000"}, + }) { + t.Error("expected match for identical requests") + } + + // Different count — should not match + if portsMatch(existing, []ServicePort{ + {Service: "web", ContainerPort: "80"}, + }) { + t.Error("expected no match for different count") + } + + // Different port — should not match + if portsMatch(existing, []ServicePort{ + {Service: "web", ContainerPort: "8080"}, + {Service: "api", ContainerPort: "3000"}, + }) { + t.Error("expected no match for different port") + } +} diff --git a/packages/agent/go/internal/roles/compose/reconciler.go b/packages/agent/go/internal/roles/compose/reconciler.go new file mode 100644 index 0000000..b4eae54 --- /dev/null +++ b/packages/agent/go/internal/roles/compose/reconciler.go @@ -0,0 +1,165 @@ +package compose + +import ( + "crypto/sha256" + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + "github.com/opsen/agent/internal/config" +) + +// Reconciler watches for client policy changes and redeploys affected projects. +type Reconciler struct { + cfg *config.AgentConfig + clientStore *config.ClientStore + tracker *ResourceTracker + ports *PortAllocator + logger *slog.Logger +} + +func NewReconciler(cfg *config.AgentConfig, clientStore *config.ClientStore, tracker *ResourceTracker, ports *PortAllocator, logger *slog.Logger) *Reconciler { + return &Reconciler{cfg: cfg, clientStore: clientStore, tracker: tracker, ports: ports, logger: logger} +} + +// Run starts the reconciliation loop. It checks for policy changes every interval +// and redeploys projects whose policy hash has changed. +func (r *Reconciler) Run(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + r.reconcile() + } +} + +func (r *Reconciler) reconcile() { + r.tracker.mu.RLock() + clientNames := make([]string, 0, len(r.tracker.Clients)) + for name := range r.tracker.Clients { + clientNames = append(clientNames, name) + } + r.tracker.mu.RUnlock() + + for _, clientName := range clientNames { + client := r.clientStore.Get(clientName) + if client == nil || client.Compose == nil { + continue + } + + currentHash := policyHash(client, r.cfg) + + r.tracker.mu.RLock() + clientRes := r.tracker.Clients[clientName] + if clientRes == nil { + r.tracker.mu.RUnlock() + continue + } + // Collect projects needing redeploy + var stale []string + for projectSlug, res := range clientRes.Projects { + if res.PolicyHash != currentHash { + stale = append(stale, projectSlug) + } + } + r.tracker.mu.RUnlock() + + for _, projectSlug := range stale { + r.redeployProject(clientName, client, projectSlug, currentHash) + } + } +} + +func (r *Reconciler) redeployProject(clientName string, client *config.ClientPolicy, projectSlug, newHash string) { + projectDir := filepath.Join(r.cfg.Roles.Compose.DeploymentsDir, clientName, projectSlug) + composePath := findComposeFileOnDisk(projectDir) + if composePath == "" { + r.logger.Warn("reconcile: no compose file on disk, skipping", "client", clientName, "project", projectSlug) + return + } + + data, err := os.ReadFile(composePath) + if err != nil { + r.logger.Error("reconcile: failed to read compose file", "client", clientName, "project", projectSlug, "error", err) + return + } + + composeFile, err := parseCompose(data) + if err != nil { + r.logger.Error("reconcile: failed to parse compose file", "client", clientName, "project", projectSlug, "error", err) + return + } + + // Re-allocate ports (expose entries were cleared by previous hardening, + // but port allocator has the existing mappings) + var portMappings []PortMapping + if r.ports != nil { + pp := r.ports.GetProject(clientName, projectSlug) + if pp != nil { + portMappings = pp.Ports + } + } + + // Re-harden with current policy + hardenCompose(composeFile, r.cfg, client, portMappings) + + // Write hardened compose file + transformed, err := marshalCompose(composeFile) + if err != nil { + r.logger.Error("reconcile: failed to serialize compose file", "client", clientName, "project", projectSlug, "error", err) + return + } + if err := os.WriteFile(composePath, transformed, 0640); err != nil { + r.logger.Error("reconcile: failed to write compose file", "client", clientName, "project", projectSlug, "error", err) + return + } + + // Run docker compose up + projectName := fmt.Sprintf("opsen-%s-%s", clientName, projectSlug) + composeBin := r.cfg.Roles.Compose.ComposeBinary + if err := composeUp(composeBin, projectName, composePath); err != nil { + r.logger.Error("reconcile: compose up failed", "client", clientName, "project", projectSlug, "error", err) + return + } + + // Update the stored policy hash + r.tracker.mu.Lock() + if clientRes, ok := r.tracker.Clients[clientName]; ok { + if res, ok := clientRes.Projects[projectSlug]; ok { + res.PolicyHash = newHash + } + } + r.tracker.save() + r.tracker.mu.Unlock() + + r.logger.Info("reconcile: redeployed project with updated policy", "client", clientName, "project", projectSlug) +} + +// policyHash computes a hash of the policy and config fields that affect compose deployments. +// If this hash changes, existing deployments need to be re-hardened. +func policyHash(client *config.ClientPolicy, cfg *config.AgentConfig) string { + h := sha256.New() + + // Client compose policy fields + if client.Compose != nil { + fmt.Fprintf(h, "bind=%s\n", client.Compose.Network.IngressBindAddress) + fmt.Fprintf(h, "internet=%v\n", client.Compose.Network.InternetAccess) + fmt.Fprintf(h, "defaultmem=%d\n", client.Compose.PerContainer.DefaultMemoryMb) + fmt.Fprintf(h, "maxpids=%d\n", client.Compose.PerContainer.MaxPids) + } + + // Global hardening fields + gh := cfg.GlobalHardening + fmt.Fprintf(h, "nonewpriv=%v\n", gh.NoNewPrivileges) + fmt.Fprintf(h, "capdropall=%v\n", gh.CapDropAll) + fmt.Fprintf(h, "readonly=%v\n", gh.ReadOnlyRootfs) + fmt.Fprintf(h, "defaultuser=%s\n", gh.DefaultUser) + fmt.Fprintf(h, "pidlimit=%d\n", gh.PidLimit) + for _, t := range gh.DefaultTmpfs { + fmt.Fprintf(h, "tmpfs=%s:%s\n", t.Path, t.Options) + } + + return fmt.Sprintf("%x", h.Sum(nil))[:16] +} diff --git a/packages/agent/go/internal/roles/compose/reconciler_test.go b/packages/agent/go/internal/roles/compose/reconciler_test.go new file mode 100644 index 0000000..e14c14f --- /dev/null +++ b/packages/agent/go/internal/roles/compose/reconciler_test.go @@ -0,0 +1,132 @@ +package compose + +import ( + "testing" + + "github.com/opsen/agent/internal/config" +) + +func TestPolicyHash_StableForSameInput(t *testing.T) { + cfg := minimalConfig() + cfg.GlobalHardening.ReadOnlyRootfs = true + cfg.GlobalHardening.PidLimit = 100 + client := minimalClient("c1") + client.Compose.Network.IngressBindAddress = "10.0.1.2" + + h1 := policyHash(client, cfg) + h2 := policyHash(client, cfg) + + if h1 != h2 { + t.Errorf("expected stable hash, got %s and %s", h1, h2) + } +} + +func TestPolicyHash_ChangesOnBindAddress(t *testing.T) { + cfg := minimalConfig() + client := minimalClient("c1") + + client.Compose.Network.IngressBindAddress = "10.0.1.2" + h1 := policyHash(client, cfg) + + client.Compose.Network.IngressBindAddress = "10.0.1.5" + h2 := policyHash(client, cfg) + + if h1 == h2 { + t.Error("expected different hash when bind address changes") + } +} + +func TestPolicyHash_ChangesOnHardening(t *testing.T) { + cfg := minimalConfig() + client := minimalClient("c1") + + cfg.GlobalHardening.ReadOnlyRootfs = false + h1 := policyHash(client, cfg) + + cfg.GlobalHardening.ReadOnlyRootfs = true + h2 := policyHash(client, cfg) + + if h1 == h2 { + t.Error("expected different hash when hardening changes") + } +} + +func TestPolicyHash_ChangesOnTmpfs(t *testing.T) { + cfg := minimalConfig() + client := minimalClient("c1") + + cfg.GlobalHardening.DefaultTmpfs = []config.TmpfsMount{{Path: "/tmp"}} + h1 := policyHash(client, cfg) + + cfg.GlobalHardening.DefaultTmpfs = []config.TmpfsMount{{Path: "/tmp"}, {Path: "/run"}} + h2 := policyHash(client, cfg) + + if h1 == h2 { + t.Error("expected different hash when tmpfs changes") + } +} + +func TestReconciler_NoRedeployWhenHashMatches(t *testing.T) { + cfg := minimalConfig() + client := minimalClient("c1") + hash := policyHash(client, cfg) + + // Simulate a tracked project with matching hash + tracker := &ResourceTracker{ + path: "/dev/null", + Clients: map[string]*ClientResources{}, + logger: testLogger(), + } + tracker.Clients["c1"] = &ClientResources{ + Projects: map[string]*ProjectResources{ + "myapp": {Containers: 1, PolicyHash: hash}, + }, + } + + // Collect stale projects (same logic as reconcile()) + tracker.mu.RLock() + var stale []string + for slug, res := range tracker.Clients["c1"].Projects { + if res.PolicyHash != policyHash(client, cfg) { + stale = append(stale, slug) + } + } + tracker.mu.RUnlock() + + if len(stale) != 0 { + t.Errorf("expected no stale projects when hash matches, got %v", stale) + } +} + +func TestReconciler_DetectsStaleWhenHashDiffers(t *testing.T) { + cfg := minimalConfig() + client := minimalClient("c1") + client.Compose.Network.IngressBindAddress = "10.0.1.2" + + // Project was deployed with old hash + tracker := &ResourceTracker{ + path: "/dev/null", + Clients: map[string]*ClientResources{}, + logger: testLogger(), + } + tracker.Clients["c1"] = &ClientResources{ + Projects: map[string]*ProjectResources{ + "myapp": {Containers: 1, PolicyHash: "old-hash"}, + }, + } + + currentHash := policyHash(client, cfg) + + tracker.mu.RLock() + var stale []string + for slug, res := range tracker.Clients["c1"].Projects { + if res.PolicyHash != currentHash { + stale = append(stale, slug) + } + } + tracker.mu.RUnlock() + + if len(stale) != 1 || stale[0] != "myapp" { + t.Errorf("expected myapp as stale, got %v", stale) + } +} diff --git a/packages/agent/go/internal/roles/compose/tracker.go b/packages/agent/go/internal/roles/compose/tracker.go index 9070055..0994bc3 100644 --- a/packages/agent/go/internal/roles/compose/tracker.go +++ b/packages/agent/go/internal/roles/compose/tracker.go @@ -15,6 +15,7 @@ type ProjectResources struct { Containers int `json:"containers"` MemoryMb int `json:"memory_mb"` Cpus float64 `json:"cpus"` + PolicyHash string `json:"policy_hash,omitempty"` // hash of policy fields affecting deployment } // TotalResources is the aggregated usage across all projects for a client. diff --git a/packages/agent/go/internal/server/server.go b/packages/agent/go/internal/server/server.go index 815a478..163a5cb 100644 --- a/packages/agent/go/internal/server/server.go +++ b/packages/agent/go/internal/server/server.go @@ -17,11 +17,12 @@ import ( ) type Server struct { - httpServer *http.Server - cfg *config.AgentConfig - clientStore *config.ClientStore - logger *slog.Logger - dbHandler *dbRole.Handler + httpServer *http.Server + cfg *config.AgentConfig + clientStore *config.ClientStore + logger *slog.Logger + dbHandler *dbRole.Handler + composeHandler *compose.Handler } func New(cfg *config.AgentConfig, clientStore *config.ClientStore, logger *slog.Logger) (*Server, error) { @@ -34,12 +35,13 @@ func New(cfg *config.AgentConfig, clientStore *config.ClientStore, logger *slog. }) // Compose role (Docker Compose project deployments) + var composeHandler *compose.Handler if cfg.Roles.Compose != nil { - ch := compose.NewHandler(cfg, clientStore, logger) - mux.HandleFunc("PUT /v1/compose/projects/{project}", withClient(clientStore, logger, ch.Deploy)) - mux.HandleFunc("DELETE /v1/compose/projects/{project}", withClient(clientStore, logger, ch.Destroy)) - mux.HandleFunc("GET /v1/compose/projects/{project}", withClient(clientStore, logger, ch.Status)) - mux.HandleFunc("GET /v1/compose/projects", withClient(clientStore, logger, ch.Status)) + composeHandler = compose.NewHandler(cfg, clientStore, logger) + mux.HandleFunc("PUT /v1/compose/projects/{project}", withClient(clientStore, logger, composeHandler.Deploy)) + mux.HandleFunc("DELETE /v1/compose/projects/{project}", withClient(clientStore, logger, composeHandler.Destroy)) + mux.HandleFunc("GET /v1/compose/projects/{project}", withClient(clientStore, logger, composeHandler.Status)) + mux.HandleFunc("GET /v1/compose/projects", withClient(clientStore, logger, composeHandler.Status)) logger.Info("compose role enabled") } @@ -101,11 +103,12 @@ func New(cfg *config.AgentConfig, clientStore *config.ClientStore, logger *slog. } return &Server{ - httpServer: httpServer, - cfg: cfg, - clientStore: clientStore, - logger: logger, - dbHandler: dbHandler, + httpServer: httpServer, + cfg: cfg, + clientStore: clientStore, + logger: logger, + dbHandler: dbHandler, + composeHandler: composeHandler, }, nil } @@ -114,6 +117,11 @@ func (s *Server) DbHandler() *dbRole.Handler { return s.dbHandler } +// ComposeHandler returns the compose role handler, if enabled. Used to start the reconciler. +func (s *Server) ComposeHandler() *compose.Handler { + return s.composeHandler +} + func (s *Server) ListenAndServeTLS() error { return s.httpServer.ListenAndServeTLS(s.cfg.TLS.Cert, s.cfg.TLS.Key) } diff --git a/packages/agent/package.json b/packages/agent/package.json index d47ebc8..c89077e 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -4,7 +4,6 @@ "description": "Pulumi component for deploying the opsen deploy agent to VMs", "main": "src/index.ts", "types": "dist/index.d.ts", - "type": "module", "files": [ "dist", "go", @@ -17,6 +16,7 @@ "test:watch": "vitest watch" }, "dependencies": { + "@opsen/docker-compose": "workspace:^", "@pulumi/command": "^1.0.0", "@pulumi/pulumi": "^3.152.0", "@pulumi/tls": "^5.0.0" diff --git a/packages/agent/src/__tests__/agent-installer.e2e.test.ts b/packages/agent/src/__tests__/agent-installer.e2e.test.ts index 8b1d3c7..bc8e52c 100644 --- a/packages/agent/src/__tests__/agent-installer.e2e.test.ts +++ b/packages/agent/src/__tests__/agent-installer.e2e.test.ts @@ -8,7 +8,7 @@ import { randomBytes } from 'node:crypto' const canRun = isPulumiAvailable() -const GO_OUT_DIR = resolve(import.meta.dirname, '..', '..', 'go', 'out') +const GO_OUT_DIR = resolve(__dirname, '..', '..', 'go', 'out') const BINARY_PATH = join(GO_OUT_DIR, 'opsen-agent') let stateDir: string @@ -52,7 +52,7 @@ describe.skipIf(!canRun)('AgentInstaller plan mode e2e', () => { projectName: 'opsen-agent-plan-e2e', stackName, program: async () => { - const { AgentInstaller } = await import('../agent-installer.js') + const { AgentInstaller } = await import('../agent-installer') new AgentInstaller('test-agent', { connection: { @@ -107,7 +107,7 @@ describe.skipIf(!canRun)('AgentInstaller plan mode e2e', () => { projectName: 'opsen-agent-plan-e2e', stackName, program: async () => { - const { AgentInstaller } = await import('../agent-installer.js') + const { AgentInstaller } = await import('../agent-installer') new AgentInstaller('test-agent', { connection: { diff --git a/packages/agent/src/agent-installer.ts b/packages/agent/src/agent-installer.ts index f0f5147..17dcd9b 100644 --- a/packages/agent/src/agent-installer.ts +++ b/packages/agent/src/agent-installer.ts @@ -3,10 +3,25 @@ import * as command from '@pulumi/command' import * as crypto from 'node:crypto' import * as fs from 'node:fs' import * as path from 'node:path' -import { serializeAgentConfig, serializeClientPolicy } from './config.js' -import type { AgentInstallerArgs } from './types.js' +import { MirrorState } from '@opsen/docker-compose' +import { serializeAgentConfig, serializeClientPolicy } from './config' +import type { AgentInstallerArgs } from './types' -const GO_SRC_DIR = path.resolve(import.meta.dirname, '..', 'go') +const GO_SRC_DIR = path.resolve(__dirname, '..', 'go') + +/** Wraps a shell command with sudo when the SSH user is not root. */ +function sudo(connUser: pulumi.Input | undefined, cmd: string): pulumi.Output { + return pulumi.output(connUser ?? 'root').apply((user) => (user === 'root' ? cmd : `sudo sh -c ${shellQuote(cmd)}`)) +} + +/** Wraps multi-line shell commands with sudo when the SSH user is not root. */ +function sudoScript(connUser: pulumi.Input | undefined, lines: string[]): pulumi.Output { + return sudo(connUser, lines.join('\n')) +} + +function shellQuote(s: string): string { + return "'" + s.replace(/'/g, "'\\''") + "'" +} export class AgentInstaller extends pulumi.ComponentResource { declare readonly endpoint: pulumi.Output @@ -16,6 +31,7 @@ export class AgentInstaller extends pulumi.ComponentResource { super('opsen:agent:Installer', name, {}, opts) const conn = args.connection + const connUser = conn.user // ─── Build binary locally in Docker ───────────────── const sourceHash = computeSourceHash(GO_SRC_DIR) @@ -44,22 +60,22 @@ export class AgentInstaller extends pulumi.ComponentResource { if (config.roles?.ingress?.configDir) { cmds.push(`chown opsen-agent:opsen-agent ${config.roles.ingress.configDir}`) } - return cmds.join('\n') + return cmds }) const setup = new command.remote.Command( `${name}-setup`, { connection: conn, - create: setupCommands, - delete: [ + create: setupCommands.apply((cmds) => sudoScript(connUser, cmds)), + delete: sudoScript(connUser, [ 'systemctl stop opsen-agent 2>/dev/null || true', 'systemctl disable opsen-agent 2>/dev/null || true', 'rm -f /etc/systemd/system/opsen-agent.service /usr/local/bin/opsen-agent', 'rm -rf /etc/opsen-agent /var/lib/opsen-agent /var/log/opsen-agent', 'userdel opsen-agent 2>/dev/null || true', 'systemctl daemon-reload', - ].join('\n'), + ]), }, { parent: this }, ) @@ -72,7 +88,7 @@ export class AgentInstaller extends pulumi.ComponentResource { `${name}-pre-upload`, { connection: conn, - create: 'systemctl stop opsen-agent 2>/dev/null || true; rm -f /usr/local/bin/opsen-agent', + create: sudo(connUser, 'systemctl stop opsen-agent 2>/dev/null || true; rm -f /usr/local/bin/opsen-agent'), triggers: [binHash], }, { parent: this, dependsOn: [build, setup] }, @@ -88,12 +104,13 @@ export class AgentInstaller extends pulumi.ComponentResource { if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }) if (!fs.existsSync(binaryPath)) fs.writeFileSync(binaryPath, '') + // Upload to /tmp first (SFTP doesn't use sudo), then move to /usr/local/bin const binary = new command.remote.CopyToRemote( `${name}-binary`, { connection: conn, source: new pulumi.asset.FileAsset(binaryPath), - remotePath: '/usr/local/bin/opsen-agent', + remotePath: '/tmp/opsen-agent', triggers: [binHash], }, { parent: this, dependsOn: [build, setup, preUpload] }, @@ -103,14 +120,14 @@ export class AgentInstaller extends pulumi.ComponentResource { `${name}-chmod`, { connection: conn, - create: 'chmod +x /usr/local/bin/opsen-agent', + create: sudo(connUser, 'mv /tmp/opsen-agent /usr/local/bin/opsen-agent && chmod +x /usr/local/bin/opsen-agent'), triggers: [binHash], }, { parent: this, dependsOn: [binary] }, ) // ─── Upload TLS certs ─────────────────────────────── - const tlsResources = uploadTlsCerts(name, conn, args.tls, this, setup) + const tlsResources = uploadTlsCerts(name, conn, connUser, args.tls, this, setup) // ─── Write agent config ───────────────────────────── const configYaml = pulumi.output(args.config).apply((c) => serializeAgentConfig(c)) @@ -120,29 +137,37 @@ export class AgentInstaller extends pulumi.ComponentResource { `${name}-config`, { connection: conn, - create: pulumi.interpolate`cat > /etc/opsen-agent/agent.yaml << 'OPSENEOF'\n${configYaml}\nOPSENEOF`, + create: configYaml.apply((yaml) => + sudo(connUser, `cat > /etc/opsen-agent/agent.yaml << 'OPSENEOF'\n${yaml}\nOPSENEOF`), + ), triggers: [configHash], }, { parent: this, dependsOn: [setup] }, ) - // ─── Write client policies ────────────────────────── - const clientResources = (args.clients ?? []).map((client) => { - const clientName = pulumi.output(client.name) - const policyYaml = pulumi.output(client).apply((c) => serializeClientPolicy(c)) - const policyHash = policyYaml.apply((y) => hashString(y)) + // ─── Write client policies via MirrorState ────────── + const clientFiles = pulumi.all((args.clients ?? []).map((c) => pulumi.output(c))).apply((clients) => + clients.map((c) => ({ + name: `${c.name}.yaml`, + parentPath: './' as const, + path: `./${c.name}.yaml`, + data: Buffer.from(serializeClientPolicy(c)), + })), + ) - return new command.remote.Command( - `${name}-client-${client.name}`, - { - connection: conn, - create: pulumi.interpolate`cat > /etc/opsen-agent/clients/${clientName}.yaml << 'OPSENEOF'\n${policyYaml}\nOPSENEOF`, - delete: pulumi.interpolate`rm -f /etc/opsen-agent/clients/${clientName}.yaml`, - triggers: [policyHash], - }, - { parent: this, dependsOn: [setup] }, - ) - }) + const mirrorConn = pulumi + .all([pulumi.output(conn.host), pulumi.output(conn.privateKey!), pulumi.output(conn.user ?? 'root')]) + .apply(([host, privateKey, user]) => ({ host, user, privateKey })) + + const clientMirror = new MirrorState( + `${name}-clients`, + { + connection: mirrorConn, + files: clientFiles, + remotePath: '/etc/opsen-agent/clients', + }, + { parent: this, dependsOn: [setup] }, + ) // ─── Systemd unit + start ─────────────────────────── const systemdUnit = buildSystemdUnit(args) @@ -152,7 +177,10 @@ export class AgentInstaller extends pulumi.ComponentResource { `${name}-service`, { connection: conn, - create: pulumi.interpolate`cat > /etc/systemd/system/opsen-agent.service << 'OPSENEOF'\n${systemdUnit}\nOPSENEOF + create: systemdUnit.apply((unit) => + sudo( + connUser, + `cat > /etc/systemd/system/opsen-agent.service << 'OPSENEOF'\n${unit}\nOPSENEOF systemctl daemon-reload systemctl enable opsen-agent systemctl restart opsen-agent @@ -163,9 +191,11 @@ done echo "opsen-agent failed to start" >&2 journalctl -u opsen-agent --no-pager -n 30 >&2 exit 1`, + ), + ), triggers: [restartTrigger], }, - { parent: this, dependsOn: [chmod, agentConfig, ...tlsResources, ...clientResources] }, + { parent: this, dependsOn: [chmod, agentConfig, ...tlsResources, clientMirror] }, ) // ─── Outputs ──────────────────────────────────────── @@ -183,6 +213,7 @@ exit 1`, function uploadTlsCerts( name: string, conn: command.types.input.remote.ConnectionArgs, + connUser: pulumi.Input | undefined, tls: AgentInstallerArgs['tls'], parent: pulumi.Resource, dependsOn: pulumi.Resource, @@ -199,9 +230,14 @@ function uploadTlsCerts( `${name}-tls-${f.key}`, { connection: conn, - create: pulumi.interpolate`cat > ${f.remotePath} << 'OPSENEOF'\n${f.content}\nOPSENEOF -chmod ${f.mode} ${f.remotePath} -chown opsen-agent:opsen-agent ${f.remotePath}`, + create: pulumi + .output(f.content) + .apply((content) => + sudo( + connUser, + `cat > ${f.remotePath} << 'OPSENEOF'\n${content}\nOPSENEOF\nchmod ${f.mode} ${f.remotePath}\nchown opsen-agent:opsen-agent ${f.remotePath}`, + ), + ), triggers: [pulumi.output(f.content).apply((c) => hashString(c))], }, { parent, dependsOn: [dependsOn] }, diff --git a/packages/agent/src/config.ts b/packages/agent/src/config.ts index 2cb0783..3c74e4a 100644 --- a/packages/agent/src/config.ts +++ b/packages/agent/src/config.ts @@ -1,5 +1,5 @@ import * as pulumi from '@pulumi/pulumi' -import type { AgentConfig, ClientPolicy } from './types.js' +import type { AgentConfig, ClientPolicy } from './types' export function serializeAgentConfig(config: pulumi.Unwrap): string { const doc: Record = { @@ -29,6 +29,7 @@ export function serializeAgentConfig(config: pulumi.Unwrap): string compose_binary: c.composeBinary ?? 'docker compose', deployments_dir: c.deploymentsDir ?? '/var/lib/opsen-agent/deployments/', network_prefix: c.networkPrefix ?? 'opsen', + ...(c.portRange ? { port_range: c.portRange } : {}), } } diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 8f6975d..6522aa3 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,5 +1,5 @@ -export { AgentInstaller } from './agent-installer.js' -export { createPlatformCA, issueAgentCert, issueClientCert } from './pki.js' +export { AgentInstaller } from './agent-installer' +export { createPlatformCA, issueAgentCert, issueClientCert } from './pki' export type { AgentInstallerArgs, AgentConfig, @@ -29,15 +29,15 @@ export type { DbAccessPolicy, DatabaseOwnerArgs, DatabaseLimitsArgs, -} from './types.js' -export type { PlatformCA, IssuedCert } from './pki.js' +} from './types' +export type { PlatformCA, IssuedCert } from './pki' // Dynamic resources -export { ComposeProject } from './resources/compose-project.js' -export type { ComposeProjectArgs } from './resources/compose-project.js' -export { IngressRoutes } from './resources/ingress-routes.js' -export type { IngressRoutesArgs, IngressRouteArgs } from './resources/ingress-routes.js' -export { Database } from './resources/database.js' -export type { DatabaseArgs } from './resources/database.js' -export { DatabaseRole } from './resources/database-role.js' -export type { DatabaseRoleArgs } from './resources/database-role.js' +export { ComposeProject } from './resources/compose-project' +export type { ComposeProjectArgs, PortMappings } from './resources/compose-project' +export { IngressRoutes } from './resources/ingress-routes' +export type { IngressRoutesArgs, IngressRouteArgs } from './resources/ingress-routes' +export { Database } from './resources/database' +export type { DatabaseArgs } from './resources/database' +export { DatabaseRole } from './resources/database-role' +export type { DatabaseRoleArgs } from './resources/database-role' diff --git a/packages/agent/src/pki.ts b/packages/agent/src/pki.ts index bbd5e61..a43ad26 100644 --- a/packages/agent/src/pki.ts +++ b/packages/agent/src/pki.ts @@ -1,6 +1,6 @@ import * as pulumi from '@pulumi/pulumi' import * as tls from '@pulumi/tls' -import type { PlatformCAArgs, AgentCertArgs, ClientCertArgs } from './types.js' +import type { PlatformCAArgs, AgentCertArgs, ClientCertArgs } from './types' export interface PlatformCA { certPem: pulumi.Output diff --git a/packages/agent/src/resources/client.ts b/packages/agent/src/resources/client.ts index 19748b2..b550ef1 100644 --- a/packages/agent/src/resources/client.ts +++ b/packages/agent/src/resources/client.ts @@ -5,7 +5,7 @@ * is never captured by Pulumi's closure serializer. */ -import type { AgentConnection } from '../types.js' +import type { AgentConnection } from '../types' export type { AgentConnection } diff --git a/packages/agent/src/resources/compose-project.ts b/packages/agent/src/resources/compose-project.ts index e12706d..59c7521 100644 --- a/packages/agent/src/resources/compose-project.ts +++ b/packages/agent/src/resources/compose-project.ts @@ -1,5 +1,5 @@ import * as pulumi from '@pulumi/pulumi' -import { type AgentConnection, agentRequest, checkResponse } from './client.js' +import { type AgentConnection, agentRequest, checkResponse } from './client' interface ComposeProjectInputs { connection: AgentConnection @@ -13,9 +13,10 @@ const composeProjectProvider: pulumi.dynamic.ResourceProvider = { files: inputs.files, }) checkResponse(resp, [200]) + const body = resp.body as Record return { id: inputs.project, - outs: { ...inputs, deployResult: resp.body }, + outs: { ...inputs, deployResult: body, ports: body.ports }, } }, @@ -32,7 +33,8 @@ const composeProjectProvider: pulumi.dynamic.ResourceProvider = { files: news.files, }) checkResponse(resp, [200]) - return { outs: { ...news, deployResult: resp.body } } + const body = resp.body as Record + return { outs: { ...news, deployResult: body, ports: body.ports } } }, async delete(id, props: ComposeProjectInputs) { @@ -61,11 +63,16 @@ export interface ComposeProjectArgs { files: pulumi.Input>> } +/** Port mappings returned by the agent: service → container_port → host_port */ +export type PortMappings = Record> + export class ComposeProject extends pulumi.dynamic.Resource { declare readonly project: pulumi.Output declare readonly deployResult: pulumi.Output + /** Allocated host port mappings: service → container_port → host_port */ + declare readonly ports: pulumi.Output constructor(name: string, args: ComposeProjectArgs, opts?: pulumi.CustomResourceOptions) { - super(composeProjectProvider, name, { ...args, deployResult: undefined }, opts) + super(composeProjectProvider, name, { ...args, deployResult: undefined, ports: undefined }, opts) } } diff --git a/packages/agent/src/resources/database-role.ts b/packages/agent/src/resources/database-role.ts index a7c1ffa..a81f565 100644 --- a/packages/agent/src/resources/database-role.ts +++ b/packages/agent/src/resources/database-role.ts @@ -1,5 +1,5 @@ import * as pulumi from '@pulumi/pulumi' -import { type AgentConnection, agentRequest, checkResponse } from './client.js' +import { type AgentConnection, agentRequest, checkResponse } from './client' interface DatabaseRoleInputs { connection: AgentConnection diff --git a/packages/agent/src/resources/database.ts b/packages/agent/src/resources/database.ts index b65f2d0..962f5c4 100644 --- a/packages/agent/src/resources/database.ts +++ b/packages/agent/src/resources/database.ts @@ -1,6 +1,6 @@ import * as pulumi from '@pulumi/pulumi' -import type { DatabaseLimitsArgs, DatabaseOwnerArgs } from '../types.js' -import { type AgentConnection, agentRequest, checkResponse } from './client.js' +import type { DatabaseLimitsArgs, DatabaseOwnerArgs } from '../types' +import { type AgentConnection, agentRequest, checkResponse } from './client' interface DatabaseInputs { connection: AgentConnection diff --git a/packages/agent/src/resources/ingress-routes.ts b/packages/agent/src/resources/ingress-routes.ts index 545b2cb..9153ca3 100644 --- a/packages/agent/src/resources/ingress-routes.ts +++ b/packages/agent/src/resources/ingress-routes.ts @@ -1,5 +1,5 @@ import * as pulumi from '@pulumi/pulumi' -import { type AgentConnection, agentRequest, checkResponse } from './client.js' +import { type AgentConnection, agentRequest, checkResponse } from './client' interface IngressRoute { name: string diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index d320505..caa979e 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -24,6 +24,8 @@ export interface ComposeRoleConfig { composeBinary?: string deploymentsDir?: string networkPrefix?: string + /** Host port range for exposed container ports, e.g. "8000-8999" */ + portRange?: string } export interface IngressRoleConfig { diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json index 859e47a..cb7c6ce 100644 --- a/packages/agent/tsconfig.json +++ b/packages/agent/tsconfig.json @@ -2,9 +2,11 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "./src", - "outDir": "./dist" + "outDir": "./dist", + "module": "commonjs", + "moduleResolution": "node" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"], - "references": [{ "path": "../testing" }] + "references": [{ "path": "../testing" }, { "path": "../docker-compose" }] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2347781..552d749 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: packages/agent: dependencies: + '@opsen/docker-compose': + specifier: workspace:^ + version: link:../docker-compose '@pulumi/command': specifier: ^1.0.0 version: 1.2.1(typescript@5.8.3)