Skip to content

Commit b641724

Browse files
committed
feat(module): add opm module crd to emit CustomResourceDefinitions
Converts a module's `#config` into a Kubernetes CRD via CUE's openapi encoder + structural-schema post-processing, enabling native kubectl/GitOps workflows alongside release files. Controller-side reconciliation is future work. - pkg/crd: name/version derivation, schema extraction, CRD assembly with provenance labels/annotations (OPM keys win over module keys). - internal/cmd/module/crd.go: Cobra command with `--group` and `-o yaml|json`. - tests/integration/module-crd: server-side dry-run apply against kind. go.mod temporarily `replace`s cuelang.org/go with the `opm-cli` branch of github.com/orvis98/cue to pick up cue-lang/cue#4331 (numeric bounds + default); drop once upstream tags a release. See openspec/changes/module-crd-poc/proposal.md for POC rules and deferred work. Signed-off-by: John Enarsson <johnenarsson@hotmail.com>
1 parent cb058c2 commit b641724

15 files changed

Lines changed: 1883 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Use `opm module` when you are starting from module source. For rendering, deploy
4646
|---------|-------------|
4747
| `module init` | Create a new module from a template |
4848
| `module vet` | Validate a module without rendering manifests |
49+
| `module crd` | Generate a CustomResourceDefinition from a module's `#config` |
4950

5051
### Release Operations (`opm release`)
5152

Taskfile.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ tasks:
8585
- go run tests/integration/inventory-ops/main.go
8686
- go run tests/integration/rel-list/main.go
8787
- go run tests/integration/rel-tree/main.go
88+
- go run tests/integration/module-crd/main.go
8889

8990
test:e2e:
9091
desc: Run end-to-end tests

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,5 @@ require (
9090
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
9191
sigs.k8s.io/yaml v1.6.0 // indirect
9292
)
93+
94+
replace cuelang.org/go => github.com/orvis98/cue v0.0.0-20260418150940-eef1cfe81700

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
cuelabs.dev/go/oci/ociregistry v0.0.0-20250722084951-074d06050084 h1:4k1yAtPvZJZQTu8DRY8muBo0LHv6TqtrE0AO5n6IPYs=
22
cuelabs.dev/go/oci/ociregistry v0.0.0-20250722084951-074d06050084/go.mod h1:4WWeZNxUO1vRoZWAHIG0KZOd6dA25ypyWuwD3ti0Tdc=
3-
cuelang.org/go v0.15.4 h1:lrkTDhqy8dveHgX1ZLQ6WmgbhD8+rXa0fD25hxEKYhw=
4-
cuelang.org/go v0.15.4/go.mod h1:NYw6n4akZcTjA7QQwJ1/gqWrrhsN4aZwhcAL0jv9rZE=
53
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
64
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
75
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
@@ -127,6 +125,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
127125
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
128126
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
129127
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
128+
github.com/orvis98/cue v0.0.0-20260418150940-eef1cfe81700 h1:iouB8Y3ckd3ot+UXaIJKUr8it8kuYzf1YNodZvTTFTs=
129+
github.com/orvis98/cue v0.0.0-20260418150940-eef1cfe81700/go.mod h1:NYw6n4akZcTjA7QQwJ1/gqWrrhsN4aZwhcAL0jv9rZE=
130130
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
131131
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
132132
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=

internal/cmd/module/crd.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package modulecmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
8+
9+
"github.com/opmodel/cli/internal/cmdutil"
10+
"github.com/opmodel/cli/internal/config"
11+
opmexit "github.com/opmodel/cli/internal/exit"
12+
"github.com/opmodel/cli/pkg/crd"
13+
"github.com/opmodel/cli/pkg/loader"
14+
)
15+
16+
// defaultCRDGroup is the API group applied to generated CRDs when the caller
17+
// does not pass --group. Aligned with the opmodel.dev registry used elsewhere
18+
// in the CLI; override per-invocation for forks or private deployments.
19+
const defaultCRDGroup = "module.opmodel.dev"
20+
21+
// NewModuleCRDCmd creates the module crd command.
22+
func NewModuleCRDCmd(cfg *config.GlobalConfig) *cobra.Command {
23+
var (
24+
groupFlag string
25+
outputFlag string
26+
)
27+
28+
c := &cobra.Command{
29+
Use: "crd [path]",
30+
Short: "Generate a CustomResourceDefinition from a module's #config",
31+
Long: `Generate a Kubernetes CustomResourceDefinition (apiextensions.k8s.io/v1) from an OPM module.
32+
33+
The module's #config definition is converted to an OpenAPI v3 schema and
34+
embedded in spec.versions[0].schema.openAPIV3Schema. The CRD group, kind,
35+
plural, singular, and version are derived from the module's metadata:
36+
37+
group --group flag (default "module.opmodel.dev")
38+
kind PascalCase of metadata.name (my-service -> MyService)
39+
plural lowercase kind + "s" (my-service -> myservices)
40+
singular lowercase kind (my-service -> myservice)
41+
version v1alpha1 if module major is 0, otherwise v<major>
42+
43+
This is a POC; the derivation rules will change once we handle irregular
44+
plurals and finer-grained version mapping. Scope is always Namespaced and no
45+
status subresource is emitted.
46+
47+
Known limitation: CUE's encoding/openapi cannot emit a schema for a #config
48+
that (directly or transitively) references a definition which embeds another
49+
definition inside a disjunction branch. The OPM catalog's schemas.#Secret
50+
uses that shape, so any module referencing #Secret in its #config will fail
51+
with an "openapi encoder cannot express" error that names the offending
52+
definition.
53+
54+
Arguments:
55+
path Path to module directory (default: current directory)
56+
57+
Examples:
58+
# Print the CRD for the module in the current directory
59+
opm module crd
60+
61+
# Generate a CRD for a module in a specific directory
62+
opm module crd ./my-module
63+
64+
# Use a custom API group
65+
opm module crd ./my-module --group example.com
66+
67+
# Emit JSON instead of YAML
68+
opm module crd ./my-module -o json`,
69+
Args: cobra.MaximumNArgs(1),
70+
RunE: func(c *cobra.Command, args []string) error {
71+
return runModuleCRD(args, cfg, groupFlag, outputFlag)
72+
},
73+
}
74+
75+
c.Flags().StringVar(&groupFlag, "group", defaultCRDGroup, "API group for the generated CRD")
76+
c.Flags().StringVarP(&outputFlag, "output", "o", "yaml", "Output format: yaml, json")
77+
78+
return c
79+
}
80+
81+
func runModuleCRD(args []string, cfg *config.GlobalConfig, groupFlag, outputFmt string) error {
82+
modulePath := cmdutil.ResolveModulePath(args)
83+
84+
if err := cmdutil.ValidateModuleInputPath(modulePath); err != nil {
85+
return &opmexit.ExitError{
86+
Code: opmexit.ExitGeneralError,
87+
Err: err,
88+
}
89+
}
90+
91+
outputFormat, err := cmdutil.ParseManifestOutputFormat(outputFmt)
92+
if err != nil {
93+
return err
94+
}
95+
96+
modVal, err := loader.LoadModulePackage(cfg.CueContext, modulePath)
97+
if err != nil {
98+
return &opmexit.ExitError{
99+
Code: opmexit.ExitGeneralError,
100+
Err: fmt.Errorf("loading module: %w", err),
101+
}
102+
}
103+
104+
crdManifest, err := crd.BuildCRD(modVal, crd.Options{Group: groupFlag})
105+
if err != nil {
106+
return &opmexit.ExitError{
107+
Code: opmexit.ExitGeneralError,
108+
Err: fmt.Errorf("building CRD: %w", err),
109+
}
110+
}
111+
112+
return cmdutil.WriteManifestOutput(
113+
[]*unstructured.Unstructured{crdManifest},
114+
outputFormat,
115+
false,
116+
"",
117+
"",
118+
)
119+
}

internal/cmd/module/crd_test.go

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package modulecmd
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
10+
"cuelang.org/go/cue/cuecontext"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/opmodel/cli/internal/config"
15+
)
16+
17+
func TestNewModuleCRDCmd(t *testing.T) {
18+
cmd := NewModuleCRDCmd(&config.GlobalConfig{})
19+
20+
assert.Equal(t, "crd [path]", cmd.Use)
21+
assert.NotEmpty(t, cmd.Short)
22+
assert.NotEmpty(t, cmd.Long)
23+
24+
groupFlag := cmd.Flags().Lookup("group")
25+
require.NotNil(t, groupFlag)
26+
assert.Equal(t, "module.opmodel.dev", groupFlag.DefValue)
27+
28+
outputFlag := cmd.Flags().Lookup("output")
29+
require.NotNil(t, outputFlag)
30+
assert.Equal(t, "o", outputFlag.Shorthand)
31+
assert.Equal(t, "yaml", outputFlag.DefValue)
32+
}
33+
34+
// TestModCRD_SimpleModule runs the command against the simple-module fixture
35+
// and verifies the YAML on stdout looks like a CRD. End-to-end coverage of
36+
// flag → load → BuildCRD → output wiring; the schema/name details are
37+
// exercised by pkg/crd tests.
38+
func TestModCRD_SimpleModule(t *testing.T) {
39+
fixtureDir := filepath.Join("..", "..", "..", "tests", "fixtures", "valid", "simple-module")
40+
if _, err := os.Stat(fixtureDir); os.IsNotExist(err) {
41+
t.Skip("Test fixture not found:", fixtureDir)
42+
}
43+
44+
tmpHome, cleanup := setupTestConfig(t)
45+
defer cleanup()
46+
47+
origHome := os.Getenv("HOME")
48+
os.Setenv("HOME", tmpHome)
49+
defer os.Setenv("HOME", origHome)
50+
51+
os.Unsetenv("OPM_REGISTRY")
52+
53+
cfg := &config.GlobalConfig{CueContext: cuecontext.New()}
54+
cmd := NewModuleCRDCmd(cfg)
55+
cmd.SetOut(&bytes.Buffer{})
56+
cmd.SetErr(&bytes.Buffer{})
57+
cmd.SetArgs([]string{fixtureDir})
58+
59+
stdout := captureStdout(t, func() {
60+
require.NoError(t, cmd.Execute())
61+
})
62+
63+
// Derived from simple-module's metadata.name ("simple-module"), version ("0.1.0"):
64+
// kind = SimpleModule, plural = simplemodules, version = v1alpha1.
65+
assert.Contains(t, stdout, "apiVersion: apiextensions.k8s.io/v1")
66+
assert.Contains(t, stdout, "kind: CustomResourceDefinition")
67+
assert.Contains(t, stdout, "name: simplemodules.module.opmodel.dev")
68+
assert.Contains(t, stdout, "kind: SimpleModule")
69+
assert.Contains(t, stdout, "listKind: SimpleModuleList")
70+
assert.Contains(t, stdout, "scope: Namespaced")
71+
assert.Contains(t, stdout, "name: v1alpha1")
72+
assert.Contains(t, stdout, "openAPIV3Schema")
73+
74+
// Provenance labels and annotations link the CRD back to the module.
75+
assert.Contains(t, stdout, "app.kubernetes.io/managed-by: opm-cli")
76+
assert.Contains(t, stdout, "module.opmodel.dev/name: simple-module")
77+
assert.Contains(t, stdout, "module.opmodel.dev/version: 0.1.0")
78+
// simple-module declares modulePath and fqn but not description or uuid.
79+
assert.Contains(t, stdout, "module.opmodel.dev/path: example.com/modules")
80+
assert.Contains(t, stdout, "module.opmodel.dev/fqn: example.com/modules/simple-module:0.1.0")
81+
assert.NotContains(t, stdout, "module.opmodel.dev/description")
82+
assert.NotContains(t, stdout, "module.opmodel.dev/uuid")
83+
}
84+
85+
func TestModCRD_CustomGroup(t *testing.T) {
86+
fixtureDir := filepath.Join("..", "..", "..", "tests", "fixtures", "valid", "simple-module")
87+
if _, err := os.Stat(fixtureDir); os.IsNotExist(err) {
88+
t.Skip("Test fixture not found:", fixtureDir)
89+
}
90+
91+
tmpHome, cleanup := setupTestConfig(t)
92+
defer cleanup()
93+
94+
origHome := os.Getenv("HOME")
95+
os.Setenv("HOME", tmpHome)
96+
defer os.Setenv("HOME", origHome)
97+
98+
os.Unsetenv("OPM_REGISTRY")
99+
100+
cfg := &config.GlobalConfig{CueContext: cuecontext.New()}
101+
cmd := NewModuleCRDCmd(cfg)
102+
cmd.SetOut(&bytes.Buffer{})
103+
cmd.SetErr(&bytes.Buffer{})
104+
cmd.SetArgs([]string{fixtureDir, "--group", "example.com"})
105+
106+
stdout := captureStdout(t, func() {
107+
require.NoError(t, cmd.Execute())
108+
})
109+
110+
assert.Contains(t, stdout, "name: simplemodules.example.com")
111+
assert.Contains(t, stdout, "group: example.com")
112+
// The CRD's own group should not fall back to the default. Provenance
113+
// labels on metadata.labels may still reference module.opmodel.dev/*,
114+
// so check group specifically rather than the raw string.
115+
assert.NotContains(t, stdout, "group: opmodel.dev")
116+
}
117+
118+
func TestModCRD_JSONOutput(t *testing.T) {
119+
fixtureDir := filepath.Join("..", "..", "..", "tests", "fixtures", "valid", "simple-module")
120+
if _, err := os.Stat(fixtureDir); os.IsNotExist(err) {
121+
t.Skip("Test fixture not found:", fixtureDir)
122+
}
123+
124+
tmpHome, cleanup := setupTestConfig(t)
125+
defer cleanup()
126+
127+
origHome := os.Getenv("HOME")
128+
os.Setenv("HOME", tmpHome)
129+
defer os.Setenv("HOME", origHome)
130+
131+
os.Unsetenv("OPM_REGISTRY")
132+
133+
cfg := &config.GlobalConfig{CueContext: cuecontext.New()}
134+
cmd := NewModuleCRDCmd(cfg)
135+
cmd.SetOut(&bytes.Buffer{})
136+
cmd.SetErr(&bytes.Buffer{})
137+
cmd.SetArgs([]string{fixtureDir, "-o", "json"})
138+
139+
stdout := captureStdout(t, func() {
140+
require.NoError(t, cmd.Execute())
141+
})
142+
143+
assert.Contains(t, stdout, `"apiVersion": "apiextensions.k8s.io/v1"`)
144+
assert.Contains(t, stdout, `"kind": "CustomResourceDefinition"`)
145+
}
146+
147+
func TestModCRD_InvalidPath(t *testing.T) {
148+
tmpHome, cleanup := setupTestConfig(t)
149+
defer cleanup()
150+
151+
origHome := os.Getenv("HOME")
152+
os.Setenv("HOME", tmpHome)
153+
defer os.Setenv("HOME", origHome)
154+
155+
cfg := &config.GlobalConfig{CueContext: cuecontext.New()}
156+
cmd := NewModuleCRDCmd(cfg)
157+
cmd.SetOut(&bytes.Buffer{})
158+
cmd.SetErr(&bytes.Buffer{})
159+
cmd.SetArgs([]string{"/does/not/exist"})
160+
161+
err := cmd.Execute()
162+
require.Error(t, err)
163+
}
164+
165+
func TestModCRD_InvalidOutputFormat(t *testing.T) {
166+
cfg := &config.GlobalConfig{CueContext: cuecontext.New()}
167+
cmd := NewModuleCRDCmd(cfg)
168+
cmd.SetOut(&bytes.Buffer{})
169+
cmd.SetErr(&bytes.Buffer{})
170+
cmd.SetArgs([]string{".", "-o", "xml"})
171+
172+
err := cmd.Execute()
173+
require.Error(t, err)
174+
assert.Contains(t, err.Error(), "invalid output format")
175+
}
176+
177+
// captureStdout replaces os.Stdout with a pipe for the duration of fn and
178+
// returns whatever was written. WriteManifestOutput writes directly to
179+
// os.Stdout, so this is the narrowest way to assert on its output.
180+
// Tests that call this must not run with t.Parallel() — os.Stdout is
181+
// process-global.
182+
func captureStdout(t *testing.T, fn func()) string {
183+
t.Helper()
184+
185+
orig := os.Stdout
186+
r, w, err := os.Pipe()
187+
require.NoError(t, err)
188+
os.Stdout = w
189+
190+
done := make(chan []byte, 1)
191+
go func() {
192+
var buf bytes.Buffer
193+
_, _ = io.Copy(&buf, r)
194+
done <- buf.Bytes()
195+
}()
196+
197+
defer func() {
198+
os.Stdout = orig
199+
}()
200+
201+
fn()
202+
203+
require.NoError(t, w.Close())
204+
out := <-done
205+
return string(out)
206+
}

internal/cmd/module/mod.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ func NewModuleCmd(cfg *config.GlobalConfig) *cobra.Command {
2323

2424
c.AddCommand(NewModuleInitCmd(cfg))
2525
c.AddCommand(NewModuleVetCmd(cfg))
26+
c.AddCommand(NewModuleCRDCmd(cfg))
2627

2728
return c
2829
}

0 commit comments

Comments
 (0)