Skip to content
Draft
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
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
103 changes: 103 additions & 0 deletions lib/compose/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Command Features

## Compose

`hypeman compose` is a lightweight way to declare a small workload for Hypeman.


```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