Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ hypeman ingress delete my-ingress
hypeman rm --force --all
```

### Compose

`hypeman compose` applies a small declarative workload file for images, instances, restart/health settings, and ingresses. See [lib/compose/README.md](lib/compose/README.md#compose).

More ingress features:
- Automatic certs
- Subdomain-based routing
Expand Down
104 changes: 104 additions & 0 deletions lib/compose/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Command Features

## Compose

`hypeman compose` is a lightweight way to declare a small workload and apply it through the existing Hypeman API. It is not a replacement for every Docker Compose feature; it focuses on the pieces Hypeman already manages well: images, instances, restart policy, health checks, and ingresses.
Comment thread
sjmiller609 marked this conversation as resolved.
Outdated

Compose files default to `hypeman.compose.yaml`:
Comment thread
sjmiller609 marked this conversation as resolved.
Outdated

```yaml
version: 1
name: hypeship-otel

services:
otelcol:
image: otel/opentelemetry-collector-contrib:0.108.0
cmd: ["--config=env:OTELCOL_CONFIG"]
env:
OTELCOL_CONFIG: ${file:otelcol.yaml}
SIGNOZ_ACCESS_TOKEN: ${env:SIGNOZ_ACCESS_TOKEN}
resources:
vcpus: 8
memory: 4GB
restart:
policy: on_failure
backoff: 5s
max_attempts: 10
healthcheck:
http:
port: 13133
path: /
interval: 10s
timeout: 2s
failure_threshold: 3
ingress:
- hostname: otel.example.com
host_port: 443
target_port: 4318
tls: true
```

### Commands

Preview the changes:

```sh
hypeman compose plan -f hypeman.compose.yaml
```

Apply the file:

```sh
hypeman compose up -f hypeman.compose.yaml
```

Delete resources owned by the file:

```sh
hypeman compose down -f hypeman.compose.yaml
```

`up` waits for newly created instances to reach `Running` by default. Use `--wait=false` to skip that wait, or `--wait-timeout 30s` to change the per-instance timeout.

If a managed instance or ingress exists but the rendered spec changed, `up` reports that replacement is required and exits without changing resources. Re-run with `--replace` to recreate changed resources.

All compose commands honor global output flags such as `--format json`, `--format yaml`, and `--transform`.

### How It Works

`plan` renders the desired resources from the compose file, checks whether referenced images exist, then compares the desired instances and ingresses against existing resources.

`up` applies the plan in order:

1. ensure referenced images exist and are ready
2. create or replace instances
3. create or replace ingresses

`down` deletes only instances and ingresses tagged as owned by the compose file. Images are left in place because they can be shared by normal `hypeman run` usage or other compose files.

Instances and ingresses get compose ownership tags:

```text
hypeman.compose.name
hypeman.compose.service
hypeman.compose.resource
hypeman.compose.hash
```

The hash is computed from the rendered resource spec before ownership tags are added. Re-running the same file is idempotent: matching resources are reported as unchanged, changed managed resources require `--replace`, and unmanaged resources with the same name are reported as conflicts.

### Environment Values

Environment values can embed local files or environment variables:

```yaml
env:
OTELCOL_CONFIG: ${file:otelcol.yaml}
SIGNOZ_ACCESS_TOKEN: ${env:SIGNOZ_ACCESS_TOKEN}
```

File paths are resolved relative to the compose file. Missing files or environment variables fail before any resources are applied.

### OTel Collector Example

The OTel collector can run from the upstream collector image without rebuilding it. Put the collector config in `otelcol.yaml`, reference it with `${file:otelcol.yaml}`, and pass `--config=env:OTELCOL_CONFIG` as the service command. Restart policy and healthcheck settings are applied to the instance create request, while ingress exposes only the collector port you choose.
72 changes: 72 additions & 0 deletions lib/compose/compose.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package compose

import (
"github.com/kernel/hypeman-go"
"github.com/kernel/hypeman-go/option"
)

const (
composeTagName = "hypeman.compose.name"
composeTagService = "hypeman.compose.service"
composeTagResource = "hypeman.compose.resource"
composeTagHash = "hypeman.compose.hash"

composeResourceInstance = "instance"
composeResourceIngress = "ingress"
)

type Runner struct {
file string
spec composeSpec
client hypeman.Client
opts []option.RequestOption
}

type UpOptions struct {
Replace bool
Wait bool
WaitTimeout string
Verbose bool
}

type Plan struct {
Name string `json:"name"`
File string `json:"file"`
Actions []Action `json:"actions"`
Summary Summary `json:"summary"`
}

type Summary struct {
Create int `json:"create"`
Replace int `json:"replace"`
Delete int `json:"delete"`
Unchanged int `json:"unchanged"`
Skip int `json:"skip"`
Conflict int `json:"conflict"`
}

type Action struct {
Action string `json:"action"`
Type string `json:"type"`
Name string `json:"name"`
Service string `json:"service,omitempty"`
Reason string `json:"reason"`

instanceID string
ingressID string
instanceInput map[string]any
ingressInput hypeman.IngressNewParams
}

func NewRunner(file string, client hypeman.Client, opts ...option.RequestOption) (*Runner, error) {
spec, err := loadComposeSpec(file)
if err != nil {
return nil, err
}
return &Runner{
file: file,
spec: spec,
client: client,
opts: opts,
}, nil
}
136 changes: 136 additions & 0 deletions lib/compose/compose_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package compose

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestLoadComposeSpecInterpolatesFilesAndEnv(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "otelcol.yaml"), []byte("receivers: {}\n"), 0644))
t.Setenv("SIGNOZ_ACCESS_TOKEN", "secret-token")

composePath := filepath.Join(dir, "hypeman.compose.yaml")
require.NoError(t, os.WriteFile(composePath, []byte(`
version: 1
name: hypeship-otel
services:
otelcol:
image: otel/opentelemetry-collector-contrib:0.108.0
cmd: ["--config=env:OTELCOL_CONFIG"]
env:
OTELCOL_CONFIG: ${file:otelcol.yaml}
SIGNOZ_ACCESS_TOKEN: ${env:SIGNOZ_ACCESS_TOKEN}
`), 0644))

spec, err := loadComposeSpec(composePath)
require.NoError(t, err)

service := spec.Services["otelcol"]
assert.Equal(t, "receivers: {}\n", service.Env["OTELCOL_CONFIG"])
assert.Equal(t, "secret-token", service.Env["SIGNOZ_ACCESS_TOKEN"])
}

func TestBuildComposeInstanceInputIncludesFuturePolicyFields(t *testing.T) {
service := composeServiceSpec{
Image: "otel/opentelemetry-collector-contrib:0.108.0",
Cmd: []string{"--config=env:OTELCOL_CONFIG"},
Env: map[string]string{
"OTELCOL_CONFIG": "receivers: {}\n",
},
Resources: composeResourcesSpec{
Vcpus: 8,
Memory: "4GB",
BandwidthUpload: "300Mbps",
BandwidthDownload: "300Mbps",
},
Restart: &composeRestartSpec{
Policy: "on-failure",
Backoff: "5s",
MaxAttempts: 10,
StableAfter: "10m",
},
Health: &composeCheckSpec{
HTTP: &composeHTTPCheckSpec{Port: 13133, Path: "/", ExpectedStatus: 200},
Interval: "10s",
Timeout: "2s",
FailureThreshold: 3,
},
}

input := buildComposeInstanceInput("hypeship-otel-otelcol", service)

assert.Equal(t, "hypeship-otel-otelcol", input["name"])
assert.Equal(t, service.Image, input["image"])
assert.Equal(t, []string{"--config=env:OTELCOL_CONFIG"}, input["cmd"])
assert.Equal(t, "4GB", input["size"])
assert.Equal(t, 8, input["vcpus"])
assert.Equal(t, map[string]any{
"backoff": "5s",
"max_attempts": 10,
"policy": "on_failure",
"stable_after": "10m",
}, input["restart_policy"])
assert.Equal(t, service.Health, input["health_check"])
assert.Equal(t, map[string]any{
"bandwidth_download": "300Mbps",
"bandwidth_upload": "300Mbps",
}, input["network"])
}

func TestDesiredResourcesUseDeterministicNamesAndTags(t *testing.T) {
runner := Runner{
spec: composeSpec{
Version: 1,
Name: "hypeship-otel",
Services: map[string]composeServiceSpec{
"otelcol": {
Image: "otel/opentelemetry-collector-contrib:0.108.0",
Ingress: []composeIngressRuleSpec{
{Hostname: "otel.example.com", HostPort: 443, TargetPort: 4318, TLS: true},
},
},
},
},
}

instances, ingresses, images, err := runner.desiredResources()
require.NoError(t, err)

require.Equal(t, []string{"otel/opentelemetry-collector-contrib:0.108.0"}, images)
require.Len(t, instances, 1)
assert.Equal(t, "hypeship-otel-otelcol", instances[0].Name)
assert.Equal(t, composeResourceInstance, instances[0].Input["tags"].(map[string]string)[composeTagResource])
assert.NotEmpty(t, instances[0].Input["tags"].(map[string]string)[composeTagHash])

require.Len(t, ingresses, 1)
assert.Equal(t, "hypeship-otel-otelcol-0", ingresses[0].Name)
assert.Equal(t, composeResourceIngress, ingresses[0].Input.Tags[composeTagResource])
assert.Equal(t, "hypeship-otel-otelcol", ingresses[0].Input.Rules[0].Target.Instance)
assert.Equal(t, int64(4318), ingresses[0].Input.Rules[0].Target.Port)
}

func TestValidateComposeSpecRejectsInvalidNames(t *testing.T) {
err := validateComposeSpec(&composeSpec{
Version: 1,
Name: "BadName",
Services: map[string]composeServiceSpec{
"api": {Image: "alpine:latest"},
},
})

require.EqualError(t, err, "compose name must contain only lowercase letters, digits, and dashes")
}

func TestConflictBlockers(t *testing.T) {
blockers := conflictBlockers([]Action{
{Action: "create", Type: "image", Name: "alpine:latest"},
{Action: "conflict", Type: "instance", Name: "app-api", Reason: "name exists without compose ownership"},
})

require.Equal(t, []string{" instance app-api: name exists without compose ownership"}, blockers)
}
Loading
Loading