Skip to content

Commit c58b929

Browse files
Infrahubclaude
authored andcommitted
feat(collect): record Infrahub edition in the bundle manifest
Docker bundles carried no signal of whether the deployment was Infrahub Community or Enterprise; Kubernetes conveyed it only implicitly through the Helm chart name. Add an explicit best-effort `edition` field to bundle_information.json, classified from the infrahub-server artifact name (.../infrahub vs .../infrahub-enterprise): on Docker from the container image, on Kubernetes from the already-resolved Helm chart name. Edition is deployment provenance, not a collector, so a detection failure is logged at debug and simply leaves the field unset (omitempty) — support never reads a guessed value. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0035926 commit c58b929

8 files changed

Lines changed: 324 additions & 14 deletions

File tree

docs/docs/collect/create.mdx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,10 @@ The manifest records what was attempted and what succeeded:
137137
"tool_version": "1.2.0",
138138
"infrahub_version": "1.5.2",
139139
"environment": "kubernetes",
140+
"edition": "enterprise",
140141
"helm": {
141142
"release_name": "infrahub",
142-
"chart": "infrahub",
143+
"chart": "infrahub-enterprise",
143144
"chart_version": "1.2.3"
144145
},
145146
"log_lines": 100000,
@@ -152,6 +153,8 @@ The manifest records what was attempted and what succeeded:
152153
}
153154
```
154155

156+
The `edition` field reports whether the deployment runs Infrahub Community or Enterprise. On Docker Compose it is classified from the `infrahub-server` container image, and on Kubernetes from the Helm chart name. It is omitted when the edition cannot be determined.
157+
155158
The bundle layout is identical on Docker Compose and Kubernetes. Inside the archive, all files live under a top-level `bundle/` directory:
156159

157160
- `bundle_information.json` - Collection manifest

specs/archive/003-collect-tool/contracts/manifest.schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@
4040
"type": "string",
4141
"enum": ["docker", "kubernetes"]
4242
},
43+
"edition": {
44+
"type": "string",
45+
"enum": ["community", "enterprise"],
46+
"description": "Detected Infrahub edition; omitted when it cannot be determined. On Docker it is classified from the infrahub-server image, on Kubernetes from the Helm chart name."
47+
},
4348
"helm": {
4449
"type": "object",
4550
"description": "Helm chart provenance of a Kubernetes install; omitted on Docker or when the install is not Helm-managed / its metadata cannot be read",

src/internal/app/collect.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ func (iops *InfrahubOps) runCollectPlan(backend EnvironmentBackend, opts Collect
257257
archivePath := filepath.Join(opts.OutputDir, fmt.Sprintf("support_bundle_%s.tar.gz", collectID))
258258
manifest := newBundleManifest(collectID, backend.Name(), opts.LogLines)
259259
populateHelmRelease(backend, manifest)
260+
populateEdition(backend, manifest)
260261

261262
logrus.WithFields(logrus.Fields{
262263
"collect_id": collectID,
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package app
2+
3+
import (
4+
"strings"
5+
6+
"github.com/sirupsen/logrus"
7+
)
8+
9+
// Infrahub edition identifiers recorded in the bundle manifest. These are the
10+
// Infrahub product editions, distinct from the Neo4j edition tracked by the
11+
// backup metadata.
12+
const (
13+
infrahubEditionCommunity = "community"
14+
infrahubEditionEnterprise = "enterprise"
15+
)
16+
17+
// editionDetector is an optional backend capability: report the Infrahub
18+
// edition (community or enterprise) of the running deployment for the bundle
19+
// manifest. The Docker backend implements it by classifying the infrahub-server
20+
// container image; on Kubernetes the edition is derived from the Helm chart name
21+
// instead (see populateEdition), so the Kubernetes backend does not implement
22+
// this interface.
23+
type editionDetector interface {
24+
// InfrahubEdition returns the detected edition, or "" when it cannot be
25+
// determined (e.g. the server is absent or its image is unrecognized).
26+
InfrahubEdition() (string, error)
27+
}
28+
29+
// populateEdition best-effort fills the manifest's edition field. It prefers a
30+
// backend that can report the edition directly (Docker, via the infrahub-server
31+
// image), and otherwise falls back to the Helm chart name already resolved into
32+
// the manifest (Kubernetes). Edition is deployment provenance, not a collector,
33+
// so a detection failure is logged at debug and simply leaves the field unset
34+
// rather than being recorded as a collector outcome.
35+
func populateEdition(backend EnvironmentBackend, manifest *BundleManifest) {
36+
if detector, ok := backend.(editionDetector); ok {
37+
edition, err := detector.InfrahubEdition()
38+
if err != nil {
39+
logrus.Debugf("Could not detect Infrahub edition: %v", err)
40+
} else if edition != "" {
41+
manifest.Edition = edition
42+
return
43+
}
44+
}
45+
46+
// Kubernetes: the Helm chart name (infrahub / infrahub-enterprise) is the
47+
// same signal, already fetched into the manifest by populateHelmRelease.
48+
if manifest.Helm != nil {
49+
if edition := classifyInfrahubEdition(manifest.Helm.Chart); edition != "" {
50+
manifest.Edition = edition
51+
}
52+
}
53+
}
54+
55+
// classifyInfrahubEdition maps an infrahub-server image reference — or a Helm
56+
// chart name — to the Infrahub edition it identifies. The enterprise artifact is
57+
// published as ".../infrahub-enterprise[:tag]" and the community one as
58+
// ".../infrahub[:tag]", so the final repository segment is the signal; a
59+
// mirrored or privately-tagged image keeps that segment. The match is exact so
60+
// an unrecognized artifact yields "" (edition unknown) rather than being
61+
// misclassified.
62+
func classifyInfrahubEdition(reference string) string {
63+
switch imageRepository(reference) {
64+
case "infrahub-enterprise":
65+
return infrahubEditionEnterprise
66+
case "infrahub":
67+
return infrahubEditionCommunity
68+
default:
69+
return ""
70+
}
71+
}
72+
73+
// imageRepository extracts the final repository segment of a container image
74+
// reference (or bare name), dropping the registry host, any tag, and any
75+
// digest: "registry.opsmill.io/opsmill/infrahub-enterprise:1.5.2" and
76+
// "opsmill/infrahub@sha256:..." reduce to "infrahub-enterprise" and "infrahub".
77+
func imageRepository(reference string) string {
78+
reference = strings.TrimSpace(reference)
79+
if reference == "" {
80+
return ""
81+
}
82+
// Drop a digest suffix (@sha256:...) before anything else.
83+
if at := strings.IndexByte(reference, '@'); at >= 0 {
84+
reference = reference[:at]
85+
}
86+
// The repository name is the final path segment; splitting on '/' first
87+
// ensures a registry "host:port" colon is never mistaken for a tag colon.
88+
if slash := strings.LastIndexByte(reference, '/'); slash >= 0 {
89+
reference = reference[slash+1:]
90+
}
91+
if colon := strings.IndexByte(reference, ':'); colon >= 0 {
92+
reference = reference[:colon]
93+
}
94+
return reference
95+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package app
2+
3+
import (
4+
"errors"
5+
"testing"
6+
)
7+
8+
func TestClassifyInfrahubEdition(t *testing.T) {
9+
cases := []struct {
10+
reference string
11+
want string
12+
}{
13+
// Community images, various registries/tags/digests.
14+
{"opsmill/infrahub:stable", infrahubEditionCommunity},
15+
{"registry.opsmill.io/opsmill/infrahub:1.5.2", infrahubEditionCommunity},
16+
{"infrahub", infrahubEditionCommunity},
17+
{"opsmill/infrahub@sha256:deadbeef", infrahubEditionCommunity},
18+
{"registry:5000/opsmill/infrahub:1.5.2", infrahubEditionCommunity},
19+
// Enterprise images.
20+
{"opsmill/infrahub-enterprise:stable", infrahubEditionEnterprise},
21+
{"registry.opsmill.io/opsmill/infrahub-enterprise:1.5.2", infrahubEditionEnterprise},
22+
{"infrahub-enterprise", infrahubEditionEnterprise},
23+
{"registry:5000/opsmill/infrahub-enterprise@sha256:abc", infrahubEditionEnterprise},
24+
// Helm chart names use the same signal.
25+
{"infrahub", infrahubEditionCommunity},
26+
{"infrahub-enterprise", infrahubEditionEnterprise},
27+
// Unrecognized references stay unknown rather than being guessed.
28+
{"", ""},
29+
{"opsmill/infrahub-enterprise-custom:1.0", ""},
30+
{"opsmill/some-other-image:1.0", ""},
31+
{"neo4j:5.20-enterprise", ""},
32+
}
33+
34+
for _, tc := range cases {
35+
if got := classifyInfrahubEdition(tc.reference); got != tc.want {
36+
t.Errorf("classifyInfrahubEdition(%q) = %q, want %q", tc.reference, got, tc.want)
37+
}
38+
}
39+
}
40+
41+
func TestImageRepository(t *testing.T) {
42+
cases := []struct {
43+
reference string
44+
want string
45+
}{
46+
{"registry.opsmill.io/opsmill/infrahub-enterprise:1.5.2", "infrahub-enterprise"},
47+
{"opsmill/infrahub@sha256:deadbeef", "infrahub"},
48+
{"registry:5000/opsmill/infrahub:1.5.2", "infrahub"},
49+
{"infrahub", "infrahub"},
50+
{" opsmill/infrahub:stable ", "infrahub"},
51+
{"", ""},
52+
}
53+
54+
for _, tc := range cases {
55+
if got := imageRepository(tc.reference); got != tc.want {
56+
t.Errorf("imageRepository(%q) = %q, want %q", tc.reference, got, tc.want)
57+
}
58+
}
59+
}
60+
61+
// editionDetectorBackend stands in for the Docker backend in populateEdition
62+
// tests: it reports an edition directly via the editionDetector capability.
63+
type editionDetectorBackend struct {
64+
bareBackend
65+
edition string
66+
err error
67+
}
68+
69+
func (e *editionDetectorBackend) InfrahubEdition() (string, error) {
70+
return e.edition, e.err
71+
}
72+
73+
var _ editionDetector = (*editionDetectorBackend)(nil)
74+
75+
func TestPopulateEdition(t *testing.T) {
76+
t.Run("docker: detector edition is recorded", func(t *testing.T) {
77+
manifest := newBundleManifest("20260703_101530", "docker", 100)
78+
populateEdition(&editionDetectorBackend{
79+
bareBackend: bareBackend{name: "docker"},
80+
edition: infrahubEditionEnterprise,
81+
}, manifest)
82+
if manifest.Edition != infrahubEditionEnterprise {
83+
t.Errorf("Edition = %q, want %q", manifest.Edition, infrahubEditionEnterprise)
84+
}
85+
})
86+
87+
t.Run("docker: an unknown edition leaves the field unset", func(t *testing.T) {
88+
manifest := newBundleManifest("20260703_101530", "docker", 100)
89+
populateEdition(&editionDetectorBackend{
90+
bareBackend: bareBackend{name: "docker"},
91+
edition: "",
92+
}, manifest)
93+
if manifest.Edition != "" {
94+
t.Errorf("Edition = %q, want empty when detection is inconclusive", manifest.Edition)
95+
}
96+
})
97+
98+
t.Run("docker: a detection error leaves the field unset without failing", func(t *testing.T) {
99+
manifest := newBundleManifest("20260703_101530", "docker", 100)
100+
populateEdition(&editionDetectorBackend{
101+
bareBackend: bareBackend{name: "docker"},
102+
err: errors.New("docker daemon unreachable"),
103+
}, manifest)
104+
if manifest.Edition != "" {
105+
t.Errorf("Edition = %q, want empty when detection errored", manifest.Edition)
106+
}
107+
})
108+
109+
t.Run("kubernetes: edition falls back to the Helm chart name", func(t *testing.T) {
110+
manifest := newBundleManifest("20260703_101530", "kubernetes", 100)
111+
manifest.Helm = &HelmRelease{ReleaseName: "infrahub", Chart: "infrahub-enterprise", ChartVersion: "1.2.3"}
112+
populateEdition(&bareBackend{name: "kubernetes"}, manifest)
113+
if manifest.Edition != infrahubEditionEnterprise {
114+
t.Errorf("Edition = %q, want %q from the Helm chart", manifest.Edition, infrahubEditionEnterprise)
115+
}
116+
})
117+
118+
t.Run("no detector and no Helm metadata leaves the field unset", func(t *testing.T) {
119+
manifest := newBundleManifest("20260703_101530", "docker", 100)
120+
populateEdition(&bareBackend{name: "docker"}, manifest)
121+
if manifest.Edition != "" {
122+
t.Errorf("Edition = %q, want empty when nothing can report it", manifest.Edition)
123+
}
124+
})
125+
}
126+
127+
func TestEditionFromContainers(t *testing.T) {
128+
cases := []struct {
129+
name string
130+
containers []composePSContainer
131+
want string
132+
}{
133+
{
134+
name: "enterprise infrahub-server image",
135+
containers: []composePSContainer{
136+
{Service: "database", Image: "neo4j:5.20-enterprise"},
137+
{Service: "infrahub-server", Image: "registry.opsmill.io/opsmill/infrahub-enterprise:1.5.2"},
138+
},
139+
want: infrahubEditionEnterprise,
140+
},
141+
{
142+
name: "community infrahub-server image",
143+
containers: []composePSContainer{
144+
{Service: "infrahub-server", Image: "opsmill/infrahub:stable"},
145+
},
146+
want: infrahubEditionCommunity,
147+
},
148+
{
149+
name: "service absent",
150+
containers: []composePSContainer{{Service: "cache", Image: "redis:7"}},
151+
want: "",
152+
},
153+
{
154+
name: "service present but image empty",
155+
containers: []composePSContainer{{Service: "infrahub-server", Image: ""}},
156+
want: "",
157+
},
158+
}
159+
160+
for _, tc := range cases {
161+
t.Run(tc.name, func(t *testing.T) {
162+
if got := editionFromContainers("infrahub-server", tc.containers); got != tc.want {
163+
t.Errorf("editionFromContainers() = %q, want %q", got, tc.want)
164+
}
165+
})
166+
}
167+
}

src/internal/app/collect_manifest.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,19 @@ type HelmRelease struct {
5454
// bundle_information.json at the bundle root. JSON field names are normative
5555
// (specs/003-collect-tool/contracts/manifest.schema.json).
5656
type BundleManifest struct {
57-
ManifestVersion int `json:"manifest_version"`
58-
CollectID string `json:"collect_id"`
59-
CreatedAt string `json:"created_at"`
60-
ToolVersion string `json:"tool_version"`
61-
InfrahubVersion string `json:"infrahub_version"`
62-
Environment string `json:"environment"`
63-
Helm *HelmRelease `json:"helm,omitempty"`
64-
LogLines int `json:"log_lines"`
65-
Collectors []CollectorResult `json:"collectors"`
57+
ManifestVersion int `json:"manifest_version"`
58+
CollectID string `json:"collect_id"`
59+
CreatedAt string `json:"created_at"`
60+
ToolVersion string `json:"tool_version"`
61+
InfrahubVersion string `json:"infrahub_version"`
62+
Environment string `json:"environment"`
63+
// Edition is the Infrahub edition (community or enterprise) of the running
64+
// deployment, detected best-effort. It is unset (omitted) when the edition
65+
// cannot be determined, so support never reads a guessed value.
66+
Edition string `json:"edition,omitempty"`
67+
Helm *HelmRelease `json:"helm,omitempty"`
68+
LogLines int `json:"log_lines"`
69+
Collectors []CollectorResult `json:"collectors"`
6670
}
6771

6872
// generateCollectID returns a UTC timestamp identifier shared by the bundle

src/internal/app/environment_docker.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,11 @@ func (d *DockerBackend) ExecStream(service string, command []string, opts *ExecO
113113
// research R3/R4) and the optional timeout-bounded and per-replica exec
114114
// capabilities the bundle diagnostics collectors prefer (research R2).
115115
var (
116-
_ collectBackend = (*DockerBackend)(nil)
117-
_ contextExecer = (*DockerBackend)(nil)
118-
_ replicaExecer = (*DockerBackend)(nil)
119-
_ contextCopier = (*DockerBackend)(nil)
116+
_ collectBackend = (*DockerBackend)(nil)
117+
_ contextExecer = (*DockerBackend)(nil)
118+
_ replicaExecer = (*DockerBackend)(nil)
119+
_ contextCopier = (*DockerBackend)(nil)
120+
_ editionDetector = (*DockerBackend)(nil)
120121
)
121122

122123
// ExecContext is the timeout-bounded variant of Exec used by the bundle
@@ -140,6 +141,7 @@ type composePSContainer struct {
140141
Name string `json:"Name"`
141142
Service string `json:"Service"`
142143
State string `json:"State"`
144+
Image string `json:"Image"`
143145
Labels string `json:"Labels"`
144146
}
145147

@@ -236,6 +238,32 @@ func (d *DockerBackend) ServiceReplicas(service string) ([]Replica, error) {
236238
return replicasFromComposePS(service, containers), nil
237239
}
238240

241+
// InfrahubEdition reports the Infrahub edition of the deployment by classifying
242+
// the infrahub-server container image (.../infrahub-enterprise vs .../infrahub),
243+
// mirroring how the Kubernetes backend's Helm chart name identifies the edition.
244+
// Stopped containers are inspected too (ps -a) so a shut-down deployment is still
245+
// classified. It returns "" (edition unknown) when the service is absent or its
246+
// image is unrecognized, so the manifest field is simply omitted.
247+
func (d *DockerBackend) InfrahubEdition() (string, error) {
248+
containers, err := d.composePSContainers(true, "infrahub-server")
249+
if err != nil {
250+
return "", fmt.Errorf("failed to inspect infrahub-server image: %w", err)
251+
}
252+
return editionFromContainers("infrahub-server", containers), nil
253+
}
254+
255+
// editionFromContainers classifies the Infrahub edition from the image of the
256+
// first container belonging to service, returning "" when none match or the
257+
// image is unrecognized.
258+
func editionFromContainers(service string, containers []composePSContainer) string {
259+
for _, container := range containers {
260+
if container.Service == service && container.Image != "" {
261+
return classifyInfrahubEdition(container.Image)
262+
}
263+
}
264+
return ""
265+
}
266+
239267
// dockerLogArgs builds the docker logs arguments for one replica.
240268
func dockerLogArgs(replica Replica, tailLines int) []string {
241269
return []string{"logs", "--tail", strconv.Itoa(tailLines), replica.Container}

0 commit comments

Comments
 (0)