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
64 changes: 49 additions & 15 deletions internal/localnet/observability.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func SetObservability(ctx context.Context, state *registry.State, wantProm, want

var promPort int
if wantProm && !cur.Prometheus {
out, port, err := enableObservabilitySidecar(ctx, state, PrometheusProfileName, "prometheus", 9090, logw)
out, port, err := enableObservabilitySidecar(ctx, state, "prometheus", 9090, logw)
if err != nil {
return res, fmt.Errorf("enable prometheus: %w\noutput:\n%s", err, out)
}
Expand All @@ -119,7 +119,7 @@ func SetObservability(ctx context.Context, state *registry.State, wantProm, want

var grafPort int
if wantGraf && !cur.Grafana {
out, port, err := enableObservabilitySidecar(ctx, state, GrafanaProfileName, "grafana", 3000, logw)
out, port, err := enableObservabilitySidecar(ctx, state, "grafana", 3000, logw)
if err != nil {
return res, fmt.Errorf("enable grafana: %w\noutput:\n%s", err, out)
}
Expand Down Expand Up @@ -200,13 +200,54 @@ func syncObservabilityProfiles(existing []string, prom, graf bool) []string {
return out
}

// observabilityUpProfiles returns the docker-compose profile set that
// must be ACTIVE for a valid `up` of the named sidecar. Grafana declares
// `depends_on: prometheus` in the overlay, so the prometheus profile has
// to be active whenever grafana is started — otherwise Compose filters
// prometheus out of the project model and rejects the whole project with
// "grafana depends on undefined service prometheus". Prometheus stands
// alone. The `--no-deps` on the `up` (see observabilityUpArgs) keeps
// activating the prometheus profile from actually starting a Prometheus
// container in a grafana-only (external-scrape) setup — it only makes
// the dependency target *defined*.
func observabilityUpProfiles(service string) []string {
if service == "grafana" {
return []string{PrometheusProfileName, GrafanaProfileName}
}
return []string{PrometheusProfileName}
}

// observabilityUpArgs builds the `docker compose … up` argv that brings
// up a single observability sidecar. It activates every profile the
// service's compose project needs to be valid (observabilityUpProfiles)
// and passes `--no-deps` so the up is scoped to exactly the named
// service — activating the prometheus profile for a grafana up makes
// grafana's dependency resolvable without dragging a Prometheus
// container up when the user only asked for Grafana.
func observabilityUpArgs(project string, envFiles, composeFiles []string, service string) []string {
args := []string{"compose", "-p", project}
for _, f := range envFiles {
args = append(args, "--env-file", f)
}
for _, f := range composeFiles {
args = append(args, "-f", f)
}
for _, p := range observabilityUpProfiles(service) {
args = append(args, "--profile", p)
}
return append(args, "up", "-d", "--no-deps", service)
}

// enableObservabilitySidecar runs the docker-compose subcommands that
// materialize the observability overlay and bring up a single named
// sidecar service under the matching per-component profile. Returns the
// captured combined output (for the error path) and the discovered host
// port. portInternal is the in-container port to look up via
// `docker compose port <svc> <port>` after the up succeeds.
func enableObservabilitySidecar(ctx context.Context, state *registry.State, profile, service string, portInternal int, logw io.Writer) (string, int, error) {
// sidecar service. The up activates the profile set the service needs to
// form a valid compose project (grafana pulls in the prometheus profile
// to satisfy its `depends_on`) and is scoped with `--no-deps` so only
// the named service is touched. Returns the captured combined output
// (for the error path) and the discovered host port. portInternal is the
// in-container port to look up via `docker compose port <svc> <port>`
// after the up succeeds.
func enableObservabilitySidecar(ctx context.Context, state *registry.State, service string, portInternal int, logw io.Writer) (string, int, error) {
// Capture the overlay's "preserving local edits" drift notices so a
// caller toggling a sidecar still learns their local dashboard /
// scrape-config copy diverges from the bundled default.
Expand Down Expand Up @@ -254,14 +295,7 @@ func enableObservabilitySidecar(ctx context.Context, state *registry.State, prof
return "", 0, fmt.Errorf("rebuild compose env: %w", err)
}

args := []string{"compose", "-p", state.ComposeProject}
for _, f := range cenv.EnvFiles {
args = append(args, "--env-file", f)
}
for _, f := range state.ComposeFiles {
args = append(args, "-f", f)
}
args = append(args, "--profile", profile, "up", "-d", service)
args := observabilityUpArgs(state.ComposeProject, cenv.EnvFiles, state.ComposeFiles, service)

cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Dir = state.ProjectDir
Expand Down
82 changes: 82 additions & 0 deletions internal/localnet/observability_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package localnet

import (
"bytes"
"context"
"path/filepath"
"reflect"
"slices"
"sort"
"testing"

Expand Down Expand Up @@ -161,3 +164,82 @@ func TestSetObservability_NilState(t *testing.T) {
t.Fatal("expected error on nil state")
}
}

// activeProfiles collects the profiles an argv activates via its
// `--profile <name>` flags (which precede the `up` subcommand).
func activeProfiles(args []string) map[string]bool {
out := map[string]bool{}
for i := 0; i+1 < len(args); i++ {
if args[i] == "--profile" {
out[args[i+1]] = true
}
}
return out
}

// TestObservabilityUpArgs_GrafanaKeepsPrometheusInProfileSet is the
// regression for the "grafana depends on undefined service prometheus"
// bring-up failure. Enabling grafana under only `--profile grafana`
// filters prometheus out of the compose project model, so grafana's
// overlay `depends_on: prometheus` no longer resolves and Compose
// rejects the whole project ("invalid compose project"). The generated
// `up` argv must therefore keep prometheus in the ACTIVE profile set
// whenever grafana is started, while `--no-deps` stops that from
// actually spinning a Prometheus container up in a grafana-only
// (external-scrape) setup. We materialize the real overlay so the argv
// is composed over the same compose file the toggle feeds docker, and
// inject a bytes.Buffer for the overlay drift writer per the repo
// testing rules.
func TestObservabilityUpArgs_GrafanaKeepsPrometheusInProfileSet(t *testing.T) {
dataDir := t.TempDir()
projectDir := t.TempDir()
var warn bytes.Buffer
overlay, err := MaterializeObservabilityOverlay(dataDir, projectDir, &warn)
if err != nil {
t.Fatalf("MaterializeObservabilityOverlay: %v", err)
}

const project = "canton-test"
envFiles := []string{filepath.Join(dataDir, "localnet.env")}
composeFiles := []string{filepath.Join(projectDir, "compose.yaml"), overlay}

// Grafana: prometheus profile MUST be active (else grafana's
// depends_on breaks the project), grafana profile active too, and
// the up scoped with --no-deps to the grafana service.
graf := observabilityUpArgs(project, envFiles, composeFiles, "grafana")
grafProfiles := activeProfiles(graf)
if !grafProfiles[PrometheusProfileName] {
t.Errorf("grafana up is missing --profile %s; grafana's depends_on would break the project.\nargs=%v",
PrometheusProfileName, graf)
}
if !grafProfiles[GrafanaProfileName] {
t.Errorf("grafana up is missing --profile %s.\nargs=%v", GrafanaProfileName, graf)
}
if !slices.Contains(graf, "--no-deps") {
t.Errorf("grafana up must pass --no-deps so activating the prometheus profile does not drag prometheus up.\nargs=%v", graf)
}
if !slices.Contains(graf, "up") || graf[len(graf)-1] != "grafana" {
t.Errorf("grafana argv should be an `up … grafana`, got %v", graf)
}
// The materialized overlay is the -f file the argv composes over.
if !slices.Contains(graf, overlay) {
t.Errorf("expected the materialized overlay %q in the compose -f set.\nargs=%v", overlay, graf)
}

// Prometheus alone does not need — and must not activate — the
// grafana profile.
prom := observabilityUpArgs(project, envFiles, composeFiles, "prometheus")
promProfiles := activeProfiles(prom)
if !promProfiles[PrometheusProfileName] {
t.Errorf("prometheus up is missing --profile %s.\nargs=%v", PrometheusProfileName, prom)
}
if promProfiles[GrafanaProfileName] {
t.Errorf("prometheus up should not activate the grafana profile.\nargs=%v", prom)
}
if !slices.Contains(prom, "--no-deps") {
t.Errorf("prometheus up must pass --no-deps.\nargs=%v", prom)
}
if prom[len(prom)-1] != "prometheus" {
t.Errorf("prometheus argv should target the prometheus service last, got %v", prom)
}
}
10 changes: 6 additions & 4 deletions internal/ui/handlers/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -1965,10 +1965,12 @@ func (r observabilityToggleRequest) resolveTargets() (prom, graf bool, ok bool)
//
// enabled=true → MaterializeObservabilityOverlay into dataDir,
// append the overlay to state.ComposeFiles if
// absent, run `docker compose ... --profile
// observability up -d prometheus grafana`,
// discover the new host port, persist into
// state.Ports["prometheus_ui"].
// absent, then bring up each requested sidecar with
// its own `docker compose ... up -d --no-deps <svc>`
// under the profile set the service needs (grafana
// also activates the prometheus profile so its
// `depends_on: prometheus` resolves). Discover the
// new host port, persist into state.Ports[...].
// enabled=false → `docker compose ... stop prometheus grafana`
// then `... rm -f prometheus grafana`. Clear the
// port from state.json. Canton + splice are
Expand Down
Loading