Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/agent-port-allocation.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 13 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions packages/agent/go/cmd/opsen-agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"os/signal"
"syscall"
"time"

"github.com/opsen/agent/internal/config"
"github.com/opsen/agent/internal/server"
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/agent/go/internal/config/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
74 changes: 69 additions & 5 deletions packages/agent/go/internal/roles/compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading