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
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
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ require (
github.com/google/go-containerregistry v0.20.7
github.com/gorilla/websocket v1.5.3
github.com/itchyny/json2yaml v0.1.4
github.com/kernel/hypeman-go v0.19.0
github.com/kernel/hypeman-go v0.20.0
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env v1.1.0
github.com/knadh/koanf/providers/file v1.2.1
Expand All @@ -25,6 +25,7 @@ require (
github.com/urfave/cli/v3 v3.3.2
golang.org/x/sys v0.38.0
golang.org/x/term v0.37.0
gopkg.in/yaml.v3 v3.0.1
)

require (
Expand Down Expand Up @@ -86,5 +87,4 @@ require (
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/grpc v1.75.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnV
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8=
github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI=
github.com/kernel/hypeman-go v0.19.0 h1:gpkTyR+JMIpEMkbbgeyjgV42TR2qQoPegttq1PsBdDU=
github.com/kernel/hypeman-go v0.19.0/go.mod h1:guRrhyP9QW/ebUS1UcZ0uZLLJeGAAhDNzSi68U4M9hI=
github.com/kernel/hypeman-go v0.20.0 h1:9kEMjtlko5oYSETwn9v829rJBv5GpcmoYjBjhjuwnBA=
github.com/kernel/hypeman-go v0.20.0/go.mod h1:guRrhyP9QW/ebUS1UcZ0uZLLJeGAAhDNzSi68U4M9hI=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
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 hypeman.InstanceNewParams
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
}
151 changes: 151 additions & 0 deletions lib/compose/compose_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package compose

import (
"encoding/json"
"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 TestBuildComposeInstanceInputIncludesPolicyFields(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)
inputJSON := map[string]any{}
inputData, err := json.Marshal(input)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(inputData, &inputJSON))

assert.Equal(t, "hypeship-otel-otelcol", inputJSON["name"])
assert.Equal(t, service.Image, inputJSON["image"])
assert.Equal(t, []any{"--config=env:OTELCOL_CONFIG"}, inputJSON["cmd"])
assert.Equal(t, "4GB", inputJSON["size"])
assert.Equal(t, float64(8), inputJSON["vcpus"])
assert.Equal(t, map[string]any{
"backoff": "5s",
"max_attempts": float64(10),
"policy": "on_failure",
"stable_after": "10m",
}, inputJSON["restart_policy"])
assert.Equal(t, map[string]any{
"failure_threshold": float64(3),
"http": map[string]any{
"expected_status": float64(200),
"path": "/",
"port": float64(13133),
},
"interval": "10s",
"timeout": "2s",
"type": "http",
}, inputJSON["health_check"])
assert.Equal(t, map[string]any{
"bandwidth_download": "300Mbps",
"bandwidth_upload": "300Mbps",
}, inputJSON["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[composeTagResource])
assert.NotEmpty(t, instances[0].Input.Tags[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