diff --git a/.ai/base.md b/.ai/base.md index 85140143..0828f89c 100644 --- a/.ai/base.md +++ b/.ai/base.md @@ -128,7 +128,8 @@ The repository ships a Claude Code plugin for framework consumers in `plugin/` ( `.claude-plugin/marketplace.json`). Two rules keep it accurate: - Files under `plugin/skills/*/references/` are generated copies of `docs/` files. Never edit them by hand; edit the - source under `docs/` and run `make sync-plugin`. + source under `docs/` and run `make sync-plugin`. The sync rewrites each relative link for the plugin layout and fails + when a link in `plugin/` resolves to no file. - When changing public API behaviour, check whether the distilled guidance in the affected `plugin/skills/*/SKILL.md` is stale and update it in the same response. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f5e52327..b672af30 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -128,7 +128,8 @@ The repository ships a Claude Code plugin for framework consumers in `plugin/` ( `.claude-plugin/marketplace.json`). Two rules keep it accurate: - Files under `plugin/skills/*/references/` are generated copies of `docs/` files. Never edit them by hand; edit the - source under `docs/` and run `make sync-plugin`. + source under `docs/` and run `make sync-plugin`. The sync rewrites each relative link for the plugin layout and fails + when a link in `plugin/` resolves to no file. - When changing public API behaviour, check whether the distilled guidance in the affected `plugin/skills/*/SKILL.md` is stale and update it in the same response. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 18d1cffa..a96099a4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -37,12 +37,18 @@ jobs: - name: Clone the code uses: actions/checkout@v7 + - name: Setup Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: true + - name: Install tools with asdf uses: asdf-vm/actions/install@v4 with: only: nodejs - - name: Check plugin references are in sync with docs + - name: Check plugin references are in sync with docs and their links resolve run: | make sync-plugin git add -A plugin diff --git a/Makefile b/Makefile index 37100b49..39339c38 100644 --- a/Makefile +++ b/Makefile @@ -96,25 +96,9 @@ fmt-md: prettier ## Format Markdown files. PLUGIN_SKILLS := plugin/skills .PHONY: sync-plugin -sync-plugin: ## Sync framework docs into the Claude plugin skill references. - rm -rf $(PLUGIN_SKILLS)/building-components/references \ - $(PLUGIN_SKILLS)/using-primitives/references \ - $(PLUGIN_SKILLS)/custom-resource-wrappers/references \ - $(PLUGIN_SKILLS)/structuring-operators/references \ - $(PLUGIN_SKILLS)/testing-operators/references - mkdir -p $(PLUGIN_SKILLS)/building-components/references \ - $(PLUGIN_SKILLS)/using-primitives/references/primitives \ - $(PLUGIN_SKILLS)/custom-resource-wrappers/references \ - $(PLUGIN_SKILLS)/structuring-operators/references \ - $(PLUGIN_SKILLS)/testing-operators/references - cp docs/component.md $(PLUGIN_SKILLS)/building-components/references/component.md - cp docs/observability.md $(PLUGIN_SKILLS)/building-components/references/observability.md - cp docs/primitives.md $(PLUGIN_SKILLS)/using-primitives/references/primitives.md - cp docs/primitives/*.md $(PLUGIN_SKILLS)/using-primitives/references/primitives/ - cp docs/custom-resource.md $(PLUGIN_SKILLS)/custom-resource-wrappers/references/custom-resource.md - cp docs/guidelines.md $(PLUGIN_SKILLS)/structuring-operators/references/guidelines.md - cp docs/compatibility.md $(PLUGIN_SKILLS)/structuring-operators/references/compatibility.md - cp docs/testing.md $(PLUGIN_SKILLS)/testing-operators/references/testing.md +sync-plugin: prettier ## Sync framework docs into the Claude plugin skill references, rewriting their links. + go run ./internal/pluginsync/syncplugin + $(PRETTIER) --write --log-level warn '$(PLUGIN_SKILLS)/*/references/**/*.md' .PHONY: prettier prettier: $(PRETTIER) ## Download prettier locally if necessary. diff --git a/internal/pluginsync/sync.go b/internal/pluginsync/sync.go new file mode 100644 index 00000000..c994f1b9 --- /dev/null +++ b/internal/pluginsync/sync.go @@ -0,0 +1,314 @@ +// Package pluginsync copies the framework docs into the reference folders of the Claude Code plugin skills and +// rewrites their relative links, so that each link resolves in the plugin layout. +package pluginsync + +import ( + "errors" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "slices" + "strings" +) + +// SiteURL is the base URL of the published docs site. A link to a doc that the plugin does not bundle points there. +const SiteURL = "https://sourcehawk.github.io/operator-component-framework/" + +// Copy maps a doc, or a glob of docs, relative to the docs folder, to its copy relative to the skills folder. Both use +// slash separators. The destination starts with /references. For a glob, the destination is a folder, and each +// match keeps its file name in it. +type Copy struct { + Source string + Dest string +} + +// Bundled lists the docs that the plugin bundles. +var Bundled = []Copy{ + {"component.md", "building-components/references/component.md"}, + {"observability.md", "building-components/references/observability.md"}, + {"primitives.md", "using-primitives/references/primitives.md"}, + {"primitives/*.md", "using-primitives/references/primitives"}, + {"custom-resource.md", "custom-resource-wrappers/references/custom-resource.md"}, + {"guidelines.md", "structuring-operators/references/guidelines.md"}, + {"compatibility.md", "structuring-operators/references/compatibility.md"}, + {"testing.md", "testing-operators/references/testing.md"}, +} + +// linkPatterns match the destination, in their first group, of an inline link or image with an optional title, and +// of a link reference definition. +var linkPatterns = []*regexp.Regexp{ + regexp.MustCompile(`\]\(([^()\s]+)(?:\s+(?:"[^"]*"|'[^']*'))?\)`), + regexp.MustCompile(`(?m)^ {0,3}\[[^\]]+\]:[ \t]*(\S+)`), +} + +// Sync copies each doc of docs from docsDir to skillsDir. It first removes each /references folder under +// skillsDir, so a doc or a skill that is no longer bundled leaves no stale copy. In each copy, a link to another copied doc points +// to the copy of that doc, and a link to a doc that is not copied points to its page on the docs site at siteURL. +// Anchors are kept, and links inside code do not change. Sync fails for a link to a doc that does not exist in +// docsDir, and for a glob that matches no doc. +func Sync(docsDir, skillsDir, siteURL string, docs []Copy) error { + copies, err := resolveCopies(docsDir, docs) + if err != nil { + return err + } + + stale, err := filepath.Glob(filepath.Join(skillsDir, "*", "references")) + if err != nil { + return fmt.Errorf("finding reference folders: %w", err) + } + for _, dir := range stale { + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("removing %s: %w", dir, err) + } + } + + for source, dest := range copies { + content, err := os.ReadFile(filepath.Join(docsDir, source)) + if err != nil { + return fmt.Errorf("reading %s: %w", source, err) + } + + rewritten, err := rewriteLinks(string(content), source, docsDir, copies, siteURL) + if err != nil { + return fmt.Errorf("rewriting links in %s: %w", source, err) + } + + target := filepath.Join(skillsDir, dest) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("creating the folder of %s: %w", dest, err) + } + if err := os.WriteFile(target, []byte(rewritten), 0o644); err != nil { + return fmt.Errorf("writing %s: %w", dest, err) + } + } + + return nil +} + +// resolveCopies expands docs into one entry per doc, keyed by the doc path relative to docsDir. +func resolveCopies(docsDir string, docs []Copy) (map[string]string, error) { + copies := make(map[string]string) + for _, c := range docs { + if !strings.ContainsAny(c.Source, "*?[") { + copies[c.Source] = c.Dest + continue + } + + matches, err := filepath.Glob(filepath.Join(docsDir, filepath.FromSlash(c.Source))) + if err != nil { + return nil, fmt.Errorf("expanding %s: %w", c.Source, err) + } + if len(matches) == 0 { + return nil, fmt.Errorf("expanding %s: no docs match", c.Source) + } + for _, m := range matches { + rel, err := filepath.Rel(docsDir, m) + if err != nil { + return nil, fmt.Errorf("expanding %s: %w", c.Source, err) + } + copies[filepath.ToSlash(rel)] = path.Join(c.Dest, filepath.Base(m)) + } + } + return copies, nil +} + +// rewriteLinks rewrites each relative link to a Markdown doc in content, a doc at source in docsDir, for its copy in +// the plugin. It returns an error for a link to a doc that does not exist in docsDir. +func rewriteLinks(content, source, docsDir string, copies map[string]string, siteURL string) (string, error) { + var errs []error + rewritten := mapLinks(content, func(target string) string { + file, anchor, _ := strings.Cut(target, "#") + if anchor != "" { + anchor = "#" + anchor + } + if !isRelative(file) || path.Ext(file) != ".md" { + return target + } + + doc := path.Join(path.Dir(source), file) + if dest, ok := copies[doc]; ok { + return relativePath(copies[source], dest) + anchor + } + + if _, err := os.Stat(filepath.Join(docsDir, filepath.FromSlash(doc))); err != nil { + errs = append(errs, fmt.Errorf("link to %s: %w", target, err)) + return target + } + return siteURL + pagePath(doc) + anchor + }) + return rewritten, errors.Join(errs...) +} + +// relativePath returns the slash path from the folder of the file from to the file to. +func relativePath(from, to string) string { + fromParts := strings.Split(path.Dir(from), "/") + toParts := strings.Split(to, "/") + + common := 0 + for common < len(fromParts) && common < len(toParts)-1 && fromParts[common] == toParts[common] { + common++ + } + + ups := slices.Repeat([]string{".."}, len(fromParts)-common) + return path.Join(append(ups, toParts[common:]...)...) +} + +// pagePath returns the path of a doc on the docs site, which serves each page as a folder. +func pagePath(doc string) string { + page := strings.TrimSuffix(doc, ".md") + if page == "index" { + return "" + } + return strings.TrimSuffix(page, "/index") + "/" +} + +// CheckLinks walks the Markdown files under root and returns an error that names each relative destination, outside +// code, of an inline link or a link reference definition that resolves to no file. +func CheckLinks(root string) error { + var errs []error + err := filepath.WalkDir(root, func(file string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || filepath.Ext(file) != ".md" { + return err + } + + content, err := os.ReadFile(file) + if err != nil { + return err + } + + mapLinks(string(content), func(target string) string { + linked, _, _ := strings.Cut(target, "#") + if !isRelative(linked) { + return target + } + if _, err := os.Stat(filepath.Join(filepath.Dir(file), filepath.FromSlash(linked))); err != nil { + errs = append(errs, fmt.Errorf("%s: link to %s resolves to no file", file, target)) + } + return target + }) + return nil + }) + if err != nil { + return fmt.Errorf("walking %s: %w", root, err) + } + return errors.Join(errs...) +} + +// isRelative reports whether a link target without its anchor is a path relative to the file that holds it. +func isRelative(target string) bool { + return target != "" && !strings.HasPrefix(target, "/") && !strings.Contains(target, ":") +} + +// mapLinks replaces the destination of each inline link and link reference definition in content with the result of +// fn. It does not change a link in a fenced code block or in an inline code span. +func mapLinks(content string, fn func(target string) string) string { + var out, paragraph strings.Builder + flush := func() { + out.WriteString(mapOutsideCodeSpans(paragraph.String(), func(text string) string { + for _, pattern := range linkPatterns { + text = replaceFirstGroup(pattern, text, fn) + } + return text + })) + paragraph.Reset() + } + + fence := "" + for _, line := range strings.SplitAfter(content, "\n") { + trimmed := strings.TrimSpace(line) + switch { + case fence != "": + out.WriteString(line) + if closesFence(trimmed, fence) { + fence = "" + } + case fenceMarker(trimmed) != "": + flush() + fence = fenceMarker(trimmed) + out.WriteString(line) + case trimmed == "": + flush() + out.WriteString(line) + default: + paragraph.WriteString(line) + } + } + flush() + return out.String() +} + +// replaceFirstGroup replaces the first group of each match of pattern in text with the result of fn. +func replaceFirstGroup(pattern *regexp.Regexp, text string, fn func(string) string) string { + var out strings.Builder + last := 0 + for _, m := range pattern.FindAllStringSubmatchIndex(text, -1) { + out.WriteString(text[last:m[2]]) + out.WriteString(fn(text[m[2]:m[3]])) + last = m[3] + } + out.WriteString(text[last:]) + return out.String() +} + +// fenceMarker returns the run of backticks or tildes that opens a fenced code block on line, or "". +func fenceMarker(line string) string { + for _, c := range []string{"`", "~"} { + run := len(line) - len(strings.TrimLeft(line, c)) + if run >= 3 { + return strings.Repeat(c, run) + } + } + return "" +} + +// closesFence reports whether line closes the fence that marker opened: a run of the same character, at least as long, +// with nothing after it. +func closesFence(line, marker string) bool { + return strings.HasPrefix(line, marker) && strings.Trim(line, marker[:1]) == "" +} + +// mapOutsideCodeSpans applies fn to each part of paragraph that is not in an inline code span. +func mapOutsideCodeSpans(paragraph string, fn func(text string) string) string { + var out strings.Builder + for { + start := strings.Index(paragraph, "`") + if start < 0 { + out.WriteString(fn(paragraph)) + return out.String() + } + + run := len(paragraph[start:]) - len(strings.TrimLeft(paragraph[start:], "`")) + delimiter := paragraph[start : start+run] + end := closingDelimiter(paragraph[start+run:], delimiter) + if end < 0 { + out.WriteString(fn(paragraph)) + return out.String() + } + + spanEnd := start + run + end + run + out.WriteString(fn(paragraph[:start])) + out.WriteString(paragraph[start:spanEnd]) + paragraph = paragraph[spanEnd:] + } +} + +// closingDelimiter returns the index in s of a run of backticks equal to delimiter, or -1. +func closingDelimiter(s, delimiter string) int { + for i := 0; i < len(s); { + j := strings.Index(s[i:], delimiter) + if j < 0 { + return -1 + } + at := i + j + if !strings.HasPrefix(s[at+len(delimiter):], "`") && (at == 0 || s[at-1] != '`') { + return at + } + i = at + len(delimiter) + for i < len(s) && s[i] == '`' { + i++ + } + } + return -1 +} diff --git a/internal/pluginsync/sync_test.go b/internal/pluginsync/sync_test.go new file mode 100644 index 00000000..b8dc2ca5 --- /dev/null +++ b/internal/pluginsync/sync_test.go @@ -0,0 +1,217 @@ +package pluginsync_test + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sourcehawk/operator-component-framework/internal/pluginsync" +) + +const siteURL = "https://docs.example.com/" + +var testCopies = []pluginsync.Copy{ + {Source: "component.md", Dest: "components/references/component.md"}, + {Source: "observability.md", Dest: "components/references/observability.md"}, + {Source: "guidelines.md", Dest: "structuring/references/guidelines.md"}, + {Source: "primitives/*.md", Dest: "primitives/references/primitives"}, +} + +func TestSync(t *testing.T) { + tests := []struct { + name string + doc string + in string + want string + }{ + { + name: "link to a doc in another skill points to its copy, anchor kept", + doc: "guidelines.md", + in: "See [status](component.md#persisting-status).\n", + want: "See [status](../../components/references/component.md#persisting-status).\n", + }, + { + name: "link to a doc in the same skill stays a sibling link", + doc: "component.md", + in: "See [alerts](observability.md).\n", + want: "See [alerts](observability.md).\n", + }, + { + name: "link from a doc in a subfolder resolves from that subfolder", + doc: "primitives/deployment.md", + in: "See [component](../component.md#status-model).\n", + want: "See [component](../../../components/references/component.md#status-model).\n", + }, + { + name: "link to a doc in a glob points to the copy of that doc", + doc: "component.md", + in: "See [Deployment](primitives/deployment.md).\n", + want: "See [Deployment](../../primitives/references/primitives/deployment.md).\n", + }, + { + name: "link to a doc that is not copied points to its page on the docs site", + doc: "guidelines.md", + in: "See [the CLI](cli.md#regenerating) and [home](index.md).\n", + want: "See [the CLI](https://docs.example.com/cli/#regenerating) and [home](https://docs.example.com/).\n", + }, + { + name: "link with a title keeps its title", + doc: "guidelines.md", + in: "See [status](component.md#status \"Status\").\n", + want: "See [status](../../components/references/component.md#status \"Status\").\n", + }, + { + name: "link reference definition points to the copy", + doc: "guidelines.md", + in: "See [status][s].\n\n[s]: component.md#status\n", + want: "See [status][s].\n\n[s]: ../../components/references/component.md#status\n", + }, + { + name: "link in an inline code span over a line break does not change", + doc: "guidelines.md", + in: "Write `cells[x](component.md) and\nmore` here.\n", + want: "Write `cells[x](component.md) and\nmore` here.\n", + }, + { + name: "a fence line with text after the marker does not close the fence", + doc: "guidelines.md", + in: "```\n[a](component.md)\n```not-a-close\n[b](component.md)\n```\n[c](component.md)\n", + want: "```\n[a](component.md)\n```not-a-close\n[b](component.md)\n```\n[c](../../components/references/component.md)\n", + }, + { + name: "external links, anchors, and links in inline code do not change", + doc: "guidelines.md", + in: "[Go](https://go.dev), [up](#top), `[x](component.md)`, ``a ` [y](component.md)``.\n", + want: "[Go](https://go.dev), [up](#top), `[x](component.md)`, ``a ` [y](component.md)``.\n", + }, + { + name: "links in a fenced code block do not change", + doc: "guidelines.md", + in: "```go\nx := cells[string](component.md)\n```\n~~~~\n[a](component.md)\n~~~~\n", + want: "```go\nx := cells[string](component.md)\n```\n~~~~\n[a](component.md)\n~~~~\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + docsDir := writeDocs(t, map[string]string{tt.doc: tt.in}) + skillsDir := t.TempDir() + + require.NoError(t, pluginsync.Sync(docsDir, skillsDir, siteURL, testCopies)) + + got, err := os.ReadFile(filepath.Join(skillsDir, destOf(tt.doc))) + require.NoError(t, err) + assert.Equal(t, tt.want, string(got)) + }) + } +} + +func TestSyncRemovesStaleCopies(t *testing.T) { + docsDir := writeDocs(t, nil) + skillsDir := t.TempDir() + stale := filepath.Join(skillsDir, "components/references/removed.md") + require.NoError(t, os.MkdirAll(filepath.Dir(stale), 0o755)) + require.NoError(t, os.WriteFile(stale, []byte("old"), 0o644)) + retired := filepath.Join(skillsDir, "retired/references/old.md") + require.NoError(t, os.MkdirAll(filepath.Dir(retired), 0o755)) + require.NoError(t, os.WriteFile(retired, []byte("old"), 0o644)) + skillFile := filepath.Join(skillsDir, "components/SKILL.md") + require.NoError(t, os.WriteFile(skillFile, []byte("skill"), 0o644)) + + require.NoError(t, pluginsync.Sync(docsDir, skillsDir, siteURL, testCopies)) + + assert.NoFileExists(t, stale) + assert.NoDirExists(t, filepath.Dir(retired)) + assert.FileExists(t, skillFile) +} + +func TestSyncFailsForLinkToMissingDoc(t *testing.T) { + docsDir := writeDocs(t, map[string]string{"guidelines.md": "See [gone](missing.md).\n"}) + + err := pluginsync.Sync(docsDir, t.TempDir(), siteURL, testCopies) + + require.Error(t, err) + assert.Contains(t, err.Error(), "missing.md") +} + +func TestSyncFailsForGlobWithNoMatch(t *testing.T) { + docsDir := writeDocs(t, nil) + copies := slices.Concat(testCopies, []pluginsync.Copy{{Source: "none/*.md", Dest: "none/references"}}) + + err := pluginsync.Sync(docsDir, t.TempDir(), siteURL, copies) + + require.Error(t, err) + assert.Contains(t, err.Error(), "none/*.md") +} + +func TestCheckLinks(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(root, "skills/a/references/target.md"), "# Target\n") + + t.Run("reports nothing when each relative link resolves", func(t *testing.T) { + dir := filepath.Join(root, "valid") + write(t, filepath.Join(dir, "doc.md"), + "[ok](../skills/a/references/target.md#anchor) [web](https://go.dev) [top](#top)\n"+ + "`[code](missing.md)`\n```\n[fenced](missing.md)\n```\n") + + assert.NoError(t, pluginsync.CheckLinks(dir)) + }) + + t.Run("names each relative link that resolves to no file", func(t *testing.T) { + dir := filepath.Join(root, "broken") + write(t, filepath.Join(dir, "nested/doc.md"), + "[a](missing.md) [b](../gone.md#x) [c](titled.md \"Title\")\n\n[d]: defined.md\n") + + err := pluginsync.CheckLinks(dir) + + require.Error(t, err) + for _, target := range []string{"missing.md", "../gone.md#x", "titled.md", "defined.md"} { + assert.Contains(t, err.Error(), target) + } + }) +} + +// writeDocs creates a docs folder with a doc for each source of testCopies, a doc that is not copied, and the docs in +// overrides. +func writeDocs(t *testing.T, overrides map[string]string) string { + t.Helper() + dir := t.TempDir() + docs := map[string]string{ + "component.md": "# Component\n", + "observability.md": "# Observability\n", + "guidelines.md": "# Guidelines\n", + "primitives/deployment.md": "# Deployment\n", + "cli.md": "# CLI\n", + "index.md": "# Home\n", + } + for doc, content := range overrides { + docs[doc] = content + } + for doc, content := range docs { + write(t, filepath.Join(dir, doc), content) + } + return dir +} + +func write(t *testing.T, file, content string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(file), 0o755)) + require.NoError(t, os.WriteFile(file, []byte(content), 0o644)) +} + +// destOf returns the destination of doc in testCopies. +func destOf(doc string) string { + if doc == "primitives/deployment.md" { + return "primitives/references/primitives/deployment.md" + } + for _, c := range testCopies { + if c.Source == doc { + return c.Dest + } + } + return "" +} diff --git a/internal/pluginsync/syncplugin/main.go b/internal/pluginsync/syncplugin/main.go new file mode 100644 index 00000000..60e3d83c --- /dev/null +++ b/internal/pluginsync/syncplugin/main.go @@ -0,0 +1,23 @@ +// Command syncplugin copies the framework docs into the Claude Code plugin skills and fails when a relative link in +// the plugin resolves to no file. Run it from the repository root, through make sync-plugin. +package main + +import ( + "fmt" + "os" + + "github.com/sourcehawk/operator-component-framework/internal/pluginsync" +) + +func main() { + if err := pluginsync.Sync("docs", "plugin/skills", pluginsync.SiteURL, pluginsync.Bundled); err != nil { + fmt.Fprintln(os.Stderr, "sync-plugin:", err) + os.Exit(1) + } + + if err := pluginsync.CheckLinks("plugin"); err != nil { + fmt.Fprintln(os.Stderr, "sync-plugin: broken links in the plugin:") + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/plugin/skills/building-components/references/component.md b/plugin/skills/building-components/references/component.md index e6af17b4..185ef7be 100644 --- a/plugin/skills/building-components/references/component.md +++ b/plugin/skills/building-components/references/component.md @@ -17,9 +17,10 @@ flowchart TD Controller --> Component --> Primitive --> Object ``` -For the broader mental model and the primitive layer beneath a component, see the [Primitives Overview](primitives.md). -For operator-structuring advice (one component per condition, thin controllers, participation modes), see the -[Guidelines](guidelines.md). +For the broader mental model and the primitive layer beneath a component, see the +[Primitives Overview](../../using-primitives/references/primitives.md). For operator-structuring advice (one component +per condition, thin controllers, participation modes), see the +[Guidelines](../../structuring-operators/references/guidelines.md). ## Building a Component @@ -93,7 +94,7 @@ cached client can miss a controller reference that the API server already has. Use the option on any resource that two custom resources can name. With the default controller reference, it replaces the rejection by the API server of a second controller with a readable condition. With `Unowned()`, it stops the forced apply of the second owner from taking the fields of the object (see -[Server-Side Apply](primitives.md#server-side-apply)). +[Server-Side Apply](../../using-primitives/references/primitives.md#server-side-apply)). Unlike a custom guard, the check also covers every path that deletes the object. During suspension, the component does not scale down or delete a resource that another owner controls. The resource counts as suspended, so the component @@ -395,7 +396,8 @@ Return the cells from your component assembly function so tests can reach them, [`examples/extraction-and-guards`](https://github.com/sourcehawk/operator-component-framework/tree/main/examples/extraction-and-guards) does. -Each managed resource must implement [`concepts.Previewable`](primitives.md#lifecycle-interfaces) (`Preview()`). All +Each managed resource must implement +[`concepts.Previewable`](../../using-primitives/references/primitives.md#lifecycle-interfaces) (`Preview()`). All built-in primitives satisfy it through `generic.BaseResource`. A custom resource must implement it to be previewable; without it, `Component.Preview` returns an error for that resource. `Preview` is the natural input for whole-component golden snapshots via `golden.AssertComponentYAML`. @@ -424,8 +426,8 @@ omit the namespace segment (for example `rbac.authorization.k8s.io/v1/ClusterRol The component also satisfies `concepts.MutationInspector` (`RegisteredMutations()` and `FiringSet()`), which surfaces the names of registered mutations and the subset that fire at the version the component was built at. A custom resource implements the same interface so version-matrix golden generation can introspect it. See -[`concepts.MutationInspector`](primitives.md#lifecycle-interfaces) for the contract and the [Testing](testing.md) guide -for how it drives version-matrix goldens. +[`concepts.MutationInspector`](../../using-primitives/references/primitives.md#lifecycle-interfaces) for the contract +and the [Testing](../../testing-operators/references/testing.md) guide for how it drives version-matrix goldens. The data-flow counterpart is `concepts.DataInspector` (`DataTopology()`), which reports the declared flow of every data cell through the component without running any extraction. See [Inspecting the topology](#inspecting-the-topology). @@ -449,14 +451,14 @@ If the owner CRD is itself cluster-scoped, owner references are set normally on ## Status Model A component reports one condition whose reason is a `component.Status` value. Which states are reachable depends on -which [lifecycle interfaces](primitives.md#lifecycle-interfaces) a resource implements: long-running workloads report -`Alive` states, run-to-completion resources report `Completable` states, externally-dependent resources report -`Operational` states, and resources implementing none of these are ready as long as they exist. The component aggregates -across all registered resources and surfaces the most critical state. +which [lifecycle interfaces](../../using-primitives/references/primitives.md#lifecycle-interfaces) a resource +implements: long-running workloads report `Alive` states, run-to-completion resources report `Completable` states, +externally-dependent resources report `Operational` states, and resources implementing none of these are ready as long +as they exist. The component aggregates across all registered resources and surfaces the most critical state. For the raw lifecycle-interface to status-string mapping, see -[Primitives Overview: Lifecycle Interfaces](primitives.md#lifecycle-interfaces). This page owns the priority and -aggregation behavior. +[Primitives Overview: Lifecycle Interfaces](../../using-primitives/references/primitives.md#lifecycle-interfaces). This +page owns the priority and aggregation behavior. ```mermaid stateDiagram-v2 @@ -504,7 +506,7 @@ priority := status.Priority() // the aggregation priority of that sta `GetCondition` reads the owner object in memory, so reconcile the component first. A condition read before `comp.Reconcile` reflects the previous pass. Prefer it over `meta.FindStatusCondition` on the owner's conditions: the synthetic `Unknown` is what keeps a not-yet-reconciled component visible to -[owner-level aggregation](guidelines.md#derive-the-owners-aggregate-condition-from-component-conditions). +[owner-level aggregation](../../structuring-operators/references/guidelines.md#derive-the-owners-aggregate-condition-from-component-conditions). ### Condition priority and aggregation @@ -519,7 +521,7 @@ reason when no non-True condition outranks it. `Status.Priority()` is the exported way to consume this ordering. A controller that derives an owner-level condition from several component conditions calls it directly, as -[Derive the Owner's Aggregate Condition from Component Conditions](guidelines.md#derive-the-owners-aggregate-condition-from-component-conditions) +[Derive the Owner's Aggregate Condition from Component Conditions](../../structuring-operators/references/guidelines.md#derive-the-owners-aggregate-condition-from-component-conditions) describes. The `Reason` on a condition **the framework writes for a component** is always a `component.Status` value, never free text, so metrics and other consumers can key on the reason string. Convert one back with `Condition.ComponentStatus()`. That guarantee does not extend to conditions on the owner written by anyone else: @@ -643,7 +645,8 @@ component.NewComponentBuilder(). During the grace period the component reports its real converging state, not a failure. After the period expires, if the component is still not ready, a `Graceful` resource's `GraceStatus()` determines the post-expiry severity: `Healthy` (no issue), `Degraded` (partially functional), or `Down` (non-functional). This prevents spurious failure alerts during -normal operations such as rolling updates. See the [Guidelines](guidelines.md) for choosing grace durations. +normal operations such as rolling updates. See the [Guidelines](../../structuring-operators/references/guidelines.md) +for choosing grace durations. A resource that reports `Blocked` was not applied, so it has no live status to grade. A resource reports `Blocked` when its [guard](#guards) blocks it, or when [`BlockOnAbsence()`](#resource-registration-options) or @@ -771,7 +774,7 @@ elsewhere. Leave it `nil` to opt out. See [Metrics](#metrics). `EventRecorder` takes a `k8s.io/client-go/tools/events.EventRecorder`. The manager accessor that returns one, `GetEventRecorder(name)`, was added in controller-runtime v0.23; on v0.22.x, build the recorder from client-go instead, -as described in [Compatibility](compatibility.md). +as described in [Compatibility](../../structuring-operators/references/compatibility.md). ## Persisting Status with FlushStatus @@ -877,8 +880,8 @@ recording is skipped. See [Metrics](#metrics). This split is what lets a controller with several components stage several conditions during one reconcile and persist them in a single write. Persisting after each component would race the components' writes and produce 409 conflicts. See -[Keep Controllers Thin](guidelines.md#keep-controllers-thin) and -[One Component Per Logical Condition](guidelines.md#one-component-per-logical-condition). +[Keep Controllers Thin](../../structuring-operators/references/guidelines.md#keep-controllers-thin) and +[One Component Per Logical Condition](../../structuring-operators/references/guidelines.md#one-component-per-logical-condition). ### One write path for the owner's status @@ -1063,7 +1066,7 @@ its result in the cell and marks it present. If it returns an error, the reconci several values from one object means several `ExtractInto` calls, one per cell. Custom resource wrappers expose the same shape by delegating to `generic.ExtractInto`; see the -[custom resource guide](custom-resource.md#5-implement-the-builder). +[custom resource guide](../../custom-resource-wrappers/references/custom-resource.md#5-implement-the-builder). ### Declaring a read @@ -1074,8 +1077,8 @@ Two builder methods record "this resource reads this cell", and both accept any object only when the value is there. Both modes are validated and both show up in the topology. The -[Guidelines](guidelines.md#use-data-extraction-and-guards-for-intra-component-dependencies) page has the table of -consumption modes and when to pick each. +[Guidelines](../../structuring-operators/references/guidelines.md#use-data-extraction-and-guards-for-intra-component-dependencies) +page has the table of consumption modes and when to pick each. ### Reset at the start of each reconcile @@ -1124,8 +1127,9 @@ for _, edge := range comp.DataTopology() { ``` `Producers`, `Guarded`, and `Optional` hold resource identities in registration order. This is the data-flow counterpart -of [`concepts.MutationInspector`](primitives.md#lifecycle-interfaces): nothing in the reconcile path calls it, and tests -can assert a component's declared data flow the same way they assert its registered mutations. +of [`concepts.MutationInspector`](../../using-primitives/references/primitives.md#lifecycle-interfaces): nothing in the +reconcile path calls it, and tests can assert a component's declared data flow the same way they assert its registered +mutations. ## Guards @@ -1264,10 +1268,12 @@ message: 'Component is down: waiting for data "backend-endpoint"' ## Component-Specific Guidance General operator-structuring advice (one component per condition, keeping controllers thin, grouping by lifecycle, -naming conditions for their audience) lives in the [Guidelines](guidelines.md). The one piece specific to this page: +naming conditions for their audience) lives in the [Guidelines](../../structuring-operators/references/guidelines.md). +The one piece specific to this page: **Use `component.Auxiliary()` for non-critical resources.** A metrics-exporter sidecar should not block your primary component from becoming ready. Every resource defaults to `ParticipationModeRequired`, so register a resource with `component.Auxiliary()` when its health should not gate the component condition. A blocked guard on an auxiliary resource still contributes, because a blocked guard halts the whole pipeline. See -[Understand Participation Modes](guidelines.md#understand-participation-modes) for the full discussion. +[Understand Participation Modes](../../structuring-operators/references/guidelines.md#understand-participation-modes) +for the full discussion. diff --git a/plugin/skills/custom-resource-wrappers/references/custom-resource.md b/plugin/skills/custom-resource-wrappers/references/custom-resource.md index 8e1a6d8f..bcb41e90 100644 --- a/plugin/skills/custom-resource-wrappers/references/custom-resource.md +++ b/plugin/skills/custom-resource-wrappers/references/custom-resource.md @@ -1,9 +1,10 @@ # Custom Resources -This guide is for operator authors who need to manage a Kubernetes object that the [built-in primitives](primitives.md) -do not cover. The built-in set handles the common kinds (Deployments, StatefulSets, ConfigMaps, Services, and more) and -is highly customizable through status handlers, suspension logic, mutations, and declared data. Reach for a custom -resource only when the kind you manage has no matching primitive: +This guide is for operator authors who need to manage a Kubernetes object that the +[built-in primitives](../../using-primitives/references/primitives.md) do not cover. The built-in set handles the common +kinds (Deployments, StatefulSets, ConfigMaps, Services, and more) and is highly customizable through status handlers, +suspension logic, mutations, and declared data. Reach for a custom resource only when the kind you manage has no +matching primitive: - A **custom CRD** defined by your project or a third-party operator. - A **standard Kubernetes kind** that the built-in set does not yet wrap. @@ -15,13 +16,13 @@ status, and mutator logic, exactly the way the built-in primitives do. !!! note "If your CRD has no typed Go struct" You can manage any CRD without writing a wrapper at all by using the unstructured static primitive - (`pkg/primitives/unstructured/static`). See [Unstructured Primitives](primitives.md#unstructured-primitives). This + (`pkg/primitives/unstructured/static`). See [Unstructured Primitives](../../using-primitives/references/primitives.md#unstructured-primitives). This guide covers the wrapper pattern, which gives you a typed, self-documenting API for a kind you manage often. !!! tip "Generate this pattern" `ocf scaffold wrapper` generates the complete package this page describes: mutator, builder, resource, and tests, - compiling and passing on a fresh scaffold. See the [CLI](cli.md) guide. This page stays the reference for what the + compiling and passing on a fresh scaffold. See the [CLI](https://sourcehawk.github.io/operator-component-framework/cli/) guide. This page stays the reference for what the generated code means and what to replace in it. --- @@ -64,7 +65,7 @@ health, so it is a **workload**. [Step 4](#4-implement-status-handlers) and the `ocf scaffold wrapper` owns exactly four files in a wrapper package: `builder.go`, `builder_test.go`, `mutator.go`, and `resource.go`. Every other file in the directory is yours. `--force` rewrites those four and leaves the rest alone, as -[Regenerating](cli.md#regenerating) describes. +[Regenerating](https://sourcehawk.github.io/operator-component-framework/cli/#regenerating) describes. This boundary is the package layout, not a workaround for one. Your real status handlers and your mutator helpers live in files of their own beside the generated four, so a regeneration cannot touch them. @@ -97,7 +98,7 @@ the registration in `builder.go`, never the handlers you wrote. The framework defines four resource categories. Each maps to a generic resource type with a different set of lifecycle interfaces. For the full description of each interface and the runtime string values it reports, see -[Lifecycle Interfaces](primitives.md#lifecycle-interfaces). +[Lifecycle Interfaces](../../using-primitives/references/primitives.md#lifecycle-interfaces). | Category | Generic type | Lifecycle interfaces | Use when | | --------------- | ----------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------ | @@ -107,8 +108,9 @@ interfaces. For the full description of each interface and the runtime string va | **Integration** | `generic.IntegrationResource` | `Operational`, `Graceful`, `Suspendable`, `Guardable`, `DataExtractable` | External-dependency objects (services, ingresses) | In addition to the category-specific interfaces, every generic resource also satisfies -[`concepts.Previewable`](primitives.md#lifecycle-interfaces), `concepts.MutationInspector`, `concepts.DataProducer`, and -`concepts.DataConsumer`, and your wrapper exposes all four. They are covered in [Step 6](#6-implement-the-resource). +[`concepts.Previewable`](../../using-primitives/references/primitives.md#lifecycle-interfaces), +`concepts.MutationInspector`, `concepts.DataProducer`, and `concepts.DataConsumer`, and your wrapper exposes all four. +They are covered in [Step 6](#6-implement-the-resource). The rest of the guide uses Workload as the primary example. The pattern is identical for the other categories, with fewer handlers to implement. @@ -150,7 +152,8 @@ scope; the framework calls it between each registered mutation to maintain per-f Mutator methods **record intent** rather than modifying the object directly. The framework calls `Apply()` once, after all mutations have been recorded. This is the same plan-and-apply model the built-in primitives use; see -[The Mutation System](primitives.md#the-mutation-system) for the rationale and the ordering guarantees. +[The Mutation System](../../using-primitives/references/primitives.md#the-mutation-system) for the rationale and the +ordering guarantees. ```go package messagequeue @@ -320,7 +323,7 @@ the loop, so the same vocabulary describes a wrapper and a built-in workload. reimplementing the per-feature snapshot inside the generated `mutator.go`, which regeneration would erase. The consequence is the one the - [mutation ordering guideline](guidelines.md#mutation-ordering-and-container-name-dependencies) already warns about, + [mutation ordering guideline](../../structuring-operators/references/guidelines.md#mutation-ordering-and-container-name-dependencies) already warns about, only with a smaller blast radius: register name-specific helper calls **before** any call that renames a container, or use name-independent selectors such as `selectors.AllContainers()`. Within a single helper call the question does not arise, because each `ContainerEditor` is scoped to the container it was given. @@ -594,8 +597,9 @@ inconsistency is intentional, pass the `component.SuppressGraceInconsistencyWarn ### Status constants reference These are the runtime **string values** each lifecycle status reports. They appear in the component's conditions and in -golden snapshots, so use the exact strings. [Lifecycle Interfaces](primitives.md#lifecycle-interfaces) gives the -authoritative interface-to-value mapping; the table here is the implementer's quick reference. +golden snapshots, so use the exact strings. +[Lifecycle Interfaces](../../using-primitives/references/primitives.md#lifecycle-interfaces) gives the authoritative +interface-to-value mapping; the table here is the implementer's quick reference. | Category | Status type | Constant | String value | | --------------------- | -------------------------------- | ------------------------------- | ------------------- | @@ -907,19 +911,19 @@ var _ concepts.MetricsIdentifiable = (*Resource)(nil) tests cannot render the resource. Every built-in resource delegates `Preview()` to its base; so must yours. `RegisteredMutations()` and `FiringSet()` satisfy `concepts.MutationInspector`. Nothing in the reconcile path calls -them, but [version-matrix golden generation](testing.md) uses them to introspect which mutations a resource registers -and which fire at a given version. Delegate both to the base, as shown. +them, but [version-matrix golden generation](../../testing-operators/references/testing.md) uses them to introspect +which mutations a resource registers and which fire at a given version. Delegate both to the base, as shown. Forward `ProducedData` and `ConsumedData` whenever the resource can take part in a component's data flow, which is always if your builder exposes `ExtractInto`, `WithDataGuard`, or `WithOptionalData`. They satisfy `concepts.DataProducer` and `concepts.DataConsumer`. Without them the component sees no declarations, so -[build-time topology validation](component.md#build-time-validation) silently passes, `DataTopology()` omits the -resource, and its cells are never cleared at the start of a reconcile. +[build-time topology validation](../../building-components/references/component.md#build-time-validation) silently +passes, `DataTopology()` omits the resource, and its cells are never cleared at the start of a reconcile. Forward `MetricsIdentifier` whenever your builder exposes `WithMetricsIdentifier`. It satisfies `concepts.MetricsIdentifiable`, which is how the framework reads the identifier at apply time. Without it the builder accepts an identifier and the framework silently labels the resource by kind instead. See -[Metrics](component.md#metrics). +[Metrics](../../building-components/references/component.md#metrics). Forward `RecordObservation` whenever the resource may be registered read-only and declares an extraction. The framework feeds the fetched cluster object back to the resource before extraction runs; without it, the extraction would see the @@ -1003,8 +1007,8 @@ func DefaultSettings(version string) []messagequeue.Mutation { Mutations apply in registration order. When a mutation's `Feature` is nil or its gate reports enabled, its `Mutate` function runs; otherwise it is skipped. For the gating model (version gates, boolean `When` conditions, and how the two -combine) see [Version-Gated Mutations](primitives.md#version-gated-mutations) and -[Boolean-Gated Mutations](primitives.md#boolean-gated-mutations). +combine) see [Version-Gated Mutations](../../using-primitives/references/primitives.md#version-gated-mutations) and +[Boolean-Gated Mutations](../../using-primitives/references/primitives.md#boolean-gated-mutations). --- @@ -1045,7 +1049,7 @@ func buildQueueComponent(owner *MyOperatorCR) (*component.Component, error) { ``` For the component reconciliation lifecycle, status aggregation, and resource options such as `ReadOnly()`, -`Auxiliary()`, and `BlockOnAbsence()`, see the [Component](component.md) page. +`Auxiliary()`, and `BlockOnAbsence()`, see the [Component](../../building-components/references/component.md) page. --- @@ -1144,10 +1148,10 @@ marshals rather than assuming: `json.Marshal` on the built desired state shows p ### Manage the kind through the unstructured primitives The other option removes the mismatch instead of correcting it. The -[unstructured primitives](primitives.md#unstructured-primitives) take a `*unstructured.Unstructured` as their baseline, -so the content map holds only the fields you put in it. No Go struct is marshalled, so no zero-value `status` appears, -and the apply carries exactly the fields the operator declares. The cost is the typed API: mutations edit the content -map through `editors.UnstructuredContentEditor` instead of typed setters. +[unstructured primitives](../../using-primitives/references/primitives.md#unstructured-primitives) take a +`*unstructured.Unstructured` as their baseline, so the content map holds only the fields you put in it. No Go struct is +marshalled, so no zero-value `status` appears, and the apply carries exactly the fields the operator declares. The cost +is the typed API: mutations edit the content map through `editors.UnstructuredContentEditor` instead of typed setters. Choose the decorator when the typed API is worth keeping and only a few paths are undeclared. Choose the unstructured primitives when the CRD's Go type and its schema diverge widely, or when the typed struct exists only to be marshalled. @@ -1166,8 +1170,8 @@ func NewBuilder(mq *examplev1.MessageQueue) *Builder { } ``` -See [Cluster-Scoped Primitives](primitives.md#cluster-scoped-primitives) for the ownership and garbage-collection -implications. +See [Cluster-Scoped Primitives](../../using-primitives/references/primitives.md#cluster-scoped-primitives) for the +ownership and garbage-collection implications. --- diff --git a/plugin/skills/structuring-operators/references/guidelines.md b/plugin/skills/structuring-operators/references/guidelines.md index 13b07842..8f7e9440 100644 --- a/plugin/skills/structuring-operators/references/guidelines.md +++ b/plugin/skills/structuring-operators/references/guidelines.md @@ -129,9 +129,9 @@ for the aggregation pattern. ## Keep Controllers Thin A controller should fetch the owner, decide which components to build, reconcile each one, and defer a single -[`component.FlushStatus`](component.md#persisting-status-with-flushstatus) to persist status. Resource construction, -feature decisions, and mutation logic belong in component-building functions, which then test as pure functions: owner -in, component out, no cluster required. +[`component.FlushStatus`](../../building-components/references/component.md#persisting-status-with-flushstatus) to +persist status. Resource construction, feature decisions, and mutation logic belong in component-building functions, +which then test as pure functions: owner in, component out, no cluster required. When a controller owns several components, reconcile them all, collect the first error but **continue on error** so one failing component does not stall the rest, and flush once at the end. @@ -198,8 +198,9 @@ enabling an optional feature changes which components the resolver returns witho Users and automation gate on one signal, not on a list of them. A controller with several components therefore stages one more condition on the owner, usually `Ready`, derived from the component conditions it just reconciled. -Use [`component.Aggregate`](component.md#aggregating-components-into-one-owner-condition) rather than deriving it by -hand: +Use +[`component.Aggregate`](../../building-components/references/component.md#aggregating-components-into-one-owner-condition) +rather than deriving it by hand: ```go ready := component.Aggregate("Ready", app, backendComp, frontendComp) @@ -207,14 +208,14 @@ meta.SetStatusCondition(app.GetStatusConditions(), metav1.Condition(ready)) ``` Stage it after the component loop and before the deferred -[`FlushStatus`](component.md#persisting-status-with-flushstatus) runs, so the aggregate reaches the API server in the -same status update as the component conditions it summarizes. +[`FlushStatus`](../../building-components/references/component.md#persisting-status-with-flushstatus) runs, so the +aggregate reaches the API server in the same status update as the component conditions it summarizes. `Aggregate` makes two decisions separately. The status is `True` if and only if every component condition is `True`, and the reason comes from the governing component, the one with the highest `Status.Priority()` among the conditions that are not `True`. -[Aggregating components into one owner condition](component.md#aggregating-components-into-one-owner-condition) carries -the full rules, the behavior table, and what to expect from suspended and gate-disabled components. +[Aggregating components into one owner condition](../../building-components/references/component.md#aggregating-components-into-one-owner-condition) +carries the full rules, the behavior table, and what to expect from suspended and gate-disabled components. **Why not derive it by hand.** The rule that looks obvious is to take the highest-priority component condition and adopt its condition status. That inverts the answer for a case operators hit in practice. `Suspended` has priority 15 and maps @@ -235,9 +236,10 @@ particular, and metrics, dashboards, and downstream automation key on the reason different prose, and leave the reason alone. **Remove the conditions of components you retire.** `Aggregate` reads only the components you pass it, and -[`FlushStatus`](component.md#persisting-status-with-flushstatus) merges conditions by type and never prunes, so a -component the controller has stopped building leaves its last condition on the owner indefinitely. The aggregate ignores -it, and users still see it in `kubectl describe`. Delete it explicitly when you retire a component: +[`FlushStatus`](../../building-components/references/component.md#persisting-status-with-flushstatus) merges conditions +by type and never prunes, so a component the controller has stopped building leaves its last condition on the owner +indefinitely. The aggregate ignores it, and users still see it in `kubectl describe`. Delete it explicitly when you +retire a component: ```go meta.RemoveStatusCondition(owner.GetStatusConditions(), "OldComponentReady") @@ -262,9 +264,9 @@ Return the error from `Reconcile` and let controller-runtime apply exponential b `reconcile.Result{RequeueAfter: ...}` unless you have a concrete reason to poll on a fixed cadence; in most cases the combination of resource watches and the manager's resync period already re-queues at the right time. A component with a grace period is the exception: no watch event arrives when its grace period ends, so requeue with the delay from -[`GraceRemaining`](component.md#requeue-when-the-grace-period-ends). Because `FlushStatus` is deferred, the owner's -conditions are written before the error propagates, so the failure is visible in status even while controller-runtime -backs off. +[`GraceRemaining`](../../building-components/references/component.md#requeue-when-the-grace-period-ends). Because +`FlushStatus` is deferred, the owner's conditions are written before the error propagates, so the failure is visible in +status even while controller-runtime backs off. Only `Reconcile` decides when to requeue. A helper or a sub-reconciler returns an error or a domain value, not a `reconcile.Result`. See @@ -281,9 +283,10 @@ guideline has three parts. A helper that changes an in-memory object does not also persist it. The layer that owns the write decides when to persist. In a controller built on the framework, that layer is `Reconcile`. For status, it writes with its one deferred -[`FlushStatus`](component.md#persisting-status-with-flushstatus). For any other write to the owner, such as a finalizer, -`Reconcile` calls `Update` or `Patch` itself, after the helper changed the object. A function that changes an object it -gets shows this in its name (`set`, `apply`, `mutate`) or in its doc comment. +[`FlushStatus`](../../building-components/references/component.md#persisting-status-with-flushstatus). For any other +write to the owner, such as a finalizer, `Reconcile` calls `Update` or `Patch` itself, after the helper changed the +object. A function that changes an object it gets shows this in its name (`set`, `apply`, `mutate`) or in its doc +comment. ```go // BAD: markSchemaPending stages a condition and also writes the status. The @@ -458,9 +461,10 @@ data extraction depend on it, and reading the calls top to bottom tells you the to reconstruct. Register dependencies before dependents. A common per-component bundle reads as a dependency chain: read-only Secret -references first (with [`BlockOnAbsence`](component.md#resource-registration-options) so an absent Secret blocks the -rest rather than erroring), then the ServiceAccount for workloads that need an identity, then the Service, then the -workload last. +references first (with +[`BlockOnAbsence`](../../building-components/references/component.md#resource-registration-options) so an absent Secret +blocks the rest rather than erroring), then the ServiceAccount for workloads that need an identity, then the Service, +then the workload last. ```go comp, err := component.NewComponentBuilder(). @@ -502,7 +506,8 @@ res, err := deployment.NewBuilder(frontendDeployment(app)). Do not work around ordering by matching multiple names (`ContainersNamed("frontend", "web")`); that couples the mutation to every name the container has ever had. The primitives overview covers the -[ordering semantics within a feature](primitives.md#ordering-within-a-feature) in full. +[ordering semantics within a feature](../../using-primitives/references/primitives.md#ordering-within-a-feature) in +full. ## Layer Mutations in a Fixed Order @@ -666,8 +671,9 @@ normal reconciliation for the first time, the prerequisite is permanently satisf Prerequisites are for **startup** ordering, not ongoing health. If the backend goes down after the frontend is already running, the frontend keeps reconciling its own resources; the two conditions reflect their own health independently. Contrast with [guards](#use-data-extraction-and-guards-for-intra-component-dependencies), which work within a single -component and re-evaluate every reconcile. See the [prerequisite behavior](component.md#prerequisite-behavior) section -for the full lifecycle. +component and re-evaluate every reconcile. See the +[prerequisite behavior](../../building-components/references/component.md#prerequisite-behavior) section for the full +lifecycle. ## Use Feature Gates for Optional Components and Conditional Resources @@ -689,8 +695,8 @@ cacheComp, err := component.NewComponentBuilder(). When the gate is disabled the framework deletes the component's resources and reports `True/Disabled`. A disabled gate takes precedence over suspension. -For a single optional resource the component owns, use [`component.GatedBy`](component.md#feature-gates) on -`WithResource`: +For a single optional resource the component owns, use +[`component.GatedBy`](../../building-components/references/component.md#feature-gates) on `WithResource`: ```go comp, _ := component.NewComponentBuilder(). @@ -703,7 +709,8 @@ comp, _ := component.NewComponentBuilder(). A disabled `GatedBy` gate deletes the resource on the next reconcile. For an optional resource the component does **not** own (a read-only Secret reference behind an optional spec field), use `IncludeWhen`, which omits the resource -without ever deleting it. The [IncludeWhen vs. GatedBy](component.md#includewhen-vs-gatedby) section covers the +without ever deleting it. The +[IncludeWhen vs. GatedBy](../../building-components/references/component.md#includewhen-vs-gatedby) section covers the distinction. ## Provide a User-Override Escape Hatch as the Last Mutation @@ -787,14 +794,14 @@ failure is recorded on the owner's condition where an operator can see it. Give every mutation a `Name`. Names appear in error reporting, and version-matrix golden manifests reference them in their `requires` and `forbids` lists, so descriptive names keep those manifests self-documenting. Name compat mutations after what they restore (`CompatV1Container`), so a reader scanning a builder chain understands each entry without -opening its implementation. See [testing.md](testing.md#firing-set-classification) for how named mutations drive -firing-set classification. +opening its implementation. See [testing.md](../../testing-operators/references/testing.md#firing-set-classification) +for how named mutations drive firing-set classification. ## Understand Participation Modes -[`component.Auxiliary()`](component.md#resource-registration-options) means "reconciled but not required for health." It -does not mean "skipped." A failing auxiliary resource still fails the reconciliation; the only difference is that its -health does not affect whether the component condition becomes Ready. +[`component.Auxiliary()`](../../building-components/references/component.md#resource-registration-options) means +"reconciled but not required for health." It does not mean "skipped." A failing auxiliary resource still fails the +reconciliation; the only difference is that its health does not affect whether the component condition becomes Ready. ```go comp, _ := component.NewComponentBuilder(). @@ -834,7 +841,7 @@ genuine failures, so choose a value that reflects expected convergence time, not The component grades a stuck resource only when it reconciles after the grace period ends, and a stuck resource often sends no watch event at that time. Return the delay from `GraceRemaining` (or `EarliestGraceRemaining` for several components) as `RequeueAfter`. See -[Requeue when the grace period ends](component.md#requeue-when-the-grace-period-ends). +[Requeue when the grace period ends](../../building-components/references/component.md#requeue-when-the-grace-period-ends). ## Handle Cluster-Scoped Resources Explicitly @@ -843,8 +850,9 @@ not allow cross-scope ownership, so the framework cannot set an owner reference. and logs the skip with its garbage-collection implication. The consequence is that those resources are **not** garbage-collected when the owner is deleted. Clean them up -explicitly with [`component.Delete()`](component.md#resource-registration-options) (or `DeleteWhen`) and a finalizer on -the owner CRD that keeps the owner alive until its cluster-scoped resources are removed. +explicitly with [`component.Delete()`](../../building-components/references/component.md#resource-registration-options) +(or `DeleteWhen`) and a finalizer on the owner CRD that keeps the owner alive until its cluster-scoped resources are +removed. ```go comp, _ := component.NewComponentBuilder(). @@ -854,8 +862,8 @@ comp, _ := component.NewComponentBuilder(). Build() ``` -The [cluster-scoped resources](component.md#cluster-scoped-resources) section covers the ownership and deletion behavior -in full. +The [cluster-scoped resources](../../building-components/references/component.md#cluster-scoped-resources) section +covers the ownership and deletion behavior in full. ## Name Resources to Avoid Multi-Tenant Collisions @@ -894,7 +902,7 @@ Use `goldengen.Resource` rather than a hand-written loop with one golden per ver them into firing regimes (one golden per distinct set of firing mutations, not one per version), asserts which mutations fire at each version, and proves through `AssertComplete` that every registered mutation is covered. A new version that fires the same mutations as an existing one adds no golden; a version that crosses a gate boundary gets its own. See -[Testing](testing.md) for the mechanics. +[Testing](../../testing-operators/references/testing.md) for the mechanics. After a deliberate baseline change, regenerate with `go test ./path -update` and review the diff. Only the regimes you meant to change should move. If an older regime's golden shifts, a compat mutation broke, and the diff shows exactly diff --git a/plugin/skills/testing-operators/references/testing.md b/plugin/skills/testing-operators/references/testing.md index c6bad2a9..fbe59356 100644 --- a/plugin/skills/testing-operators/references/testing.md +++ b/plugin/skills/testing-operators/references/testing.md @@ -97,8 +97,9 @@ type ComponentPreviewer interface { All built-in primitives satisfy `Previewer` through `generic.BaseResource`. A built `*component.Component` satisfies `ComponentPreviewer` through its `Preview` method. If you are implementing a custom resource wrapper, your built -resource must also satisfy `Previewer` for golden tests to work. See [Custom Resources](custom-resource.md) for how to -implement `Preview` on a custom resource. +resource must also satisfy `Previewer` for golden tests to work. See +[Custom Resources](../../custom-resource-wrappers/references/custom-resource.md) for how to implement `Preview` on a +custom resource. ### Assert a single resource @@ -159,9 +160,10 @@ convention, so the files are invisible to the compiler. `AssertComponentYAML` previews every resource a component would apply and serializes them into one multi-document YAML stream (`---` separated, in apply order). `buildComponent` here is your own helper that assembles the component with -`component.NewComponentBuilder` (see [Getting Started](getting-started.md#step-5-wire-the-reconciler) for building one); -extract it from your reconciler so the test and the controller build the component the same way. A built -`*component.Component` satisfies `golden.ComponentPreviewer` directly, so no type assertion is needed. +`component.NewComponentBuilder` (see +[Getting Started](https://sourcehawk.github.io/operator-component-framework/getting-started/#step-5-wire-the-reconciler) +for building one); extract it from your reconciler so the test and the controller build the component the same way. A +built `*component.Component` satisfies `golden.ComponentPreviewer` directly, so no type assertion is needed. ```go func TestComponentGolden(t *testing.T) { @@ -299,8 +301,9 @@ a separate component-level set. `goldengen.Resource` requires that the primitive satisfies both `concepts.MutationInspector` (for `RegisteredMutations` and `FiringSet`) and `concepts.Previewable` (for `Preview`); `goldengen.Component` requires the equivalent on a `*component.Component`. All built-in primitives satisfy both through `generic.BaseResource`, and a built component -satisfies them by aggregating its resources. For custom resources, see [Custom Resources](custom-resource.md) for how to -implement `MutationInspector`. +satisfies them by aggregating its resources. For custom resources, see +[Custom Resources](../../custom-resource-wrappers/references/custom-resource.md) for how to implement +`MutationInspector`. ### Run the sweep @@ -613,10 +616,10 @@ assert.Equal(t, app.Generation, ready.ObservedGeneration) `"Ready"` is your own aggregate condition type here, not a framework-fixed name; a single component's condition type reads the same way. `meta.FindStatusCondition` is the right reader, because the test is a consumer of the persisted object. Inside the controller, aggregation reads component conditions through -[`GetCondition`](component.md#reading-a-components-condition) instead, for the synthetic `Unknown` it returns before the -first reconcile. Assert the reason as well as the status: the reason is a -[`component.Status`](component.md#condition-priority-and-aggregation) value, so it pins which component governed the -result and not merely that something was ready. +[`GetCondition`](../../building-components/references/component.md#reading-a-components-condition) instead, for the +synthetic `Unknown` it returns before the first reconcile. Assert the reason as well as the status: the reason is a +[`component.Status`](../../building-components/references/component.md#condition-priority-and-aggregation) value, so it +pins which component governed the result and not merely that something was ready. Two assertions are worth writing at this layer and nowhere else: that a not-yet-reconciled component holds the owner at `False`, and that a component built with `Suspend(true)` reports `True` while the resource is gone from the cluster. diff --git a/plugin/skills/using-primitives/references/primitives.md b/plugin/skills/using-primitives/references/primitives.md index d14fb66f..83fc112f 100644 --- a/plugin/skills/using-primitives/references/primitives.md +++ b/plugin/skills/using-primitives/references/primitives.md @@ -1,8 +1,8 @@ # Primitives Overview The `primitives` packages provide reusable, type-safe wrappers for individual Kubernetes objects. A primitive sits -between the [Component layer](component.md) and a raw Kubernetes resource, handling state synchronization, mutation, and -lifecycle so operator authors do not have to. +between the [Component layer](../../building-components/references/component.md) and a raw Kubernetes resource, handling +state synchronization, mutation, and lifecycle so operator authors do not have to. This page is the canonical reference for the concepts shared across every primitive: the lifecycle interfaces and the status values they report, the mutation system, editors and selectors, Server-Side Apply, and cluster-scoped handling. @@ -76,8 +76,8 @@ A primitive participates in status aggregation by implementing one or more lifec !!! note "This table is the single source of truth" - Other documentation links here for the interface-to-status mapping. The [component page](component.md) owns how - these values are prioritized and aggregated; the [custom resource guide](custom-resource.md) owns the Go constant + Other documentation links here for the interface-to-status mapping. The [component page](../../building-components/references/component.md) owns how + these values are prioritized and aggregated; the [custom resource guide](../../custom-resource-wrappers/references/custom-resource.md) owns the Go constant reference for implementers. | Interface | Reported status values | Typical kinds | @@ -119,7 +119,8 @@ cluster-scoped object must not have a namespace A cluster-scoped builder also provides an identity function that omits the namespace segment (for example `rbac.authorization.k8s.io/v1/ClusterRole/my-role`). At reconcile time the framework detects scope mismatches between the owner CRD and managed resources using the cluster's REST mapper. See -[Cluster-Scoped Resources](component.md#cluster-scoped-resources) for owner-reference and garbage-collection behavior. +[Cluster-Scoped Resources](../../building-components/references/component.md#cluster-scoped-resources) for +owner-reference and garbage-collection behavior. ## Server-Side Apply @@ -151,13 +152,14 @@ The rejection depends on the controller reference. For a resource registered wit reference cannot be set because of a scope mismatch, nothing stops the second owner's forced apply from taking the fields it declares, and the fields move between the two owners' managers on every reconcile. -Register the resource with [`component.BlockOnForeignController()`](component.md#resource-registration-options) to make -the contention visible. Before each apply, the component reads the live object. If the object has a controller reference -to another owner, the resource reports `Blocked` and names that owner instead of applying. In the default case, this -also turns the rejection by the API server into a readable condition. The check compares controller references only, so -two owners that both apply without one leave no identity on the object (two `Unowned()` registrations, or owners that -the scope of the object keeps from being referenced). Then the fields keep moving between the two managers, and a shared -name remains the responsibility of the operator. +Register the resource with +[`component.BlockOnForeignController()`](../../building-components/references/component.md#resource-registration-options) +to make the contention visible. Before each apply, the component reads the live object. If the object has a controller +reference to another owner, the resource reports `Blocked` and names that owner instead of applying. In the default +case, this also turns the rejection by the API server into a readable condition. The check compares controller +references only, so two owners that both apply without one leave no identity on the object (two `Unowned()` +registrations, or owners that the scope of the object keeps from being referenced). Then the fields keep moving between +the two managers, and a shared name remains the responsibility of the operator. !!! note "Upgrading from a release without the UID in the manager name" @@ -193,7 +195,7 @@ primitives coexist with other controllers that touch the same resources. between mutation and apply, so the two honest options are to sanitize the object in a `client.Client` decorator installed in `ReconcileContext.Client`, or to manage the kind through the [unstructured primitives](#unstructured-primitives), whose content map holds only the fields you put in it. See - [When Server-Side Apply Rejects a Typed Object](custom-resource.md#when-server-side-apply-rejects-a-typed-object) + [When Server-Side Apply Rejects a Typed Object](../../custom-resource-wrappers/references/custom-resource.md#when-server-side-apply-rejects-a-typed-object) for both, including why a decorator must decode the server's response back into the typed object. ## The Mutation System @@ -409,8 +411,9 @@ object's name carries a generated suffix. The identifier is a Prometheus label value, not a Kubernetes name, so it must be low-cardinality and stable across reconciles. Never derive it from a per-owner value such as the owning custom resource's name. `Build` rejects a blank -identifier; omit the call to accept the default. See [Metrics](component.md#metrics) for the series, their labels, and -the cardinality contract. +identifier; omit the call to accept the default. See +[Metrics](../../building-components/references/component.md#metrics) for the series, their labels, and the cardinality +contract. ## Built-in Primitives @@ -540,11 +543,11 @@ the result with a component. !!! note "Guards versus prerequisites" - A [guard](component.md#guards) handles a dependency **within** one component: an earlier resource extracts a value - into a [data cell](component.md#declared-data) after it is applied, and a later resource blocks on that cell before + A [guard](../../building-components/references/component.md#guards) handles a dependency **within** one component: an earlier resource extracts a value + into a [data cell](../../building-components/references/component.md#declared-data) after it is applied, and a later resource blocks on that cell before proceeding. For a dependency **between** components (the frontend cannot start until the backend is ready), use - [prerequisites](component.md#prerequisites) on the component builder instead. See - [Guards](component.md#guards) for the full behavioral contract. + [prerequisites](../../building-components/references/component.md#prerequisites) on the component builder instead. See + [Guards](../../building-components/references/component.md#guards) for the full behavioral contract. ## Unstructured Primitives @@ -571,5 +574,5 @@ When the built-in primitives do not cover your kind, implement a custom resource including your own CRDs. The framework provides generic building blocks in `pkg/generic` that handle reconciliation mechanics, mutation sequencing, and suspension, so you supply only the type-specific logic. -See the [Custom Resource Implementation Guide](custom-resource.md) for a complete walkthrough covering mutator design, -status handlers, builders, and component registration. +See the [Custom Resource Implementation Guide](../../custom-resource-wrappers/references/custom-resource.md) for a +complete walkthrough covering mutator design, status handlers, builders, and component registration. diff --git a/plugin/skills/using-primitives/references/primitives/clusterrole.md b/plugin/skills/using-primitives/references/primitives/clusterrole.md index cd7a24eb..58dfc488 100644 --- a/plugin/skills/using-primitives/references/primitives/clusterrole.md +++ b/plugin/skills/using-primitives/references/primitives/clusterrole.md @@ -9,7 +9,7 @@ object metadata within the component lifecycle. controller owner reference (the scopes are incompatible). The owner reference is skipped and the skip is logged. The `ClusterRole` is **not** garbage-collected when the owner is deleted. Manage its lifecycle explicitly (for example with a finalizer on the owner) or use a cluster-scoped owner if automatic cleanup is required. See - [Cluster-Scoped Resources](../component.md#cluster-scoped-resources) for the full behavior. + [Cluster-Scoped Resources](../../../building-components/references/component.md#cluster-scoped-resources) for the full behavior. ## Capabilities @@ -207,7 +207,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(roleName)` or read it -opportunistically with `WithOptionalData(roleName)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(roleName)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Full Example diff --git a/plugin/skills/using-primitives/references/primitives/clusterrolebinding.md b/plugin/skills/using-primitives/references/primitives/clusterrolebinding.md index 900d440b..9ca3d146 100644 --- a/plugin/skills/using-primitives/references/primitives/clusterrolebinding.md +++ b/plugin/skills/using-primitives/references/primitives/clusterrolebinding.md @@ -8,7 +8,7 @@ metadata within the component lifecycle. When a namespaced owner manages a cluster-scoped resource such as a `ClusterRoleBinding`, the framework cannot set a controller owner reference (the scopes are incompatible). The owner reference is skipped and the skip is logged. The `ClusterRoleBinding` is **not** garbage-collected when the owner is deleted. Manage its lifecycle explicitly (for example with a finalizer on the owner) or use a cluster-scoped owner if automatic cleanup is required. See - [Cluster-Scoped Resources](../component.md#cluster-scoped-resources) for the full behavior. + [Cluster-Scoped Resources](../../../building-components/references/component.md#cluster-scoped-resources) for the full behavior. ## Capabilities @@ -190,7 +190,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(bindingName)` or read it -opportunistically with `WithOptionalData(bindingName)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(bindingName)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Full Example diff --git a/plugin/skills/using-primitives/references/primitives/configmap.md b/plugin/skills/using-primitives/references/primitives/configmap.md index 89889c2c..1e32fb41 100644 --- a/plugin/skills/using-primitives/references/primitives/configmap.md +++ b/plugin/skills/using-primitives/references/primitives/configmap.md @@ -284,7 +284,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(bootstrapServers)` or read it -opportunistically with `WithOptionalData(bootstrapServers)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(bootstrapServers)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Full Example diff --git a/plugin/skills/using-primitives/references/primitives/cronjob.md b/plugin/skills/using-primitives/references/primitives/cronjob.md index 742dd6df..6c39b6e1 100644 --- a/plugin/skills/using-primitives/references/primitives/cronjob.md +++ b/plugin/skills/using-primitives/references/primitives/cronjob.md @@ -215,7 +215,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(lastSchedule)` or read it -opportunistically with `WithOptionalData(lastSchedule)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(lastSchedule)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Operational Status diff --git a/plugin/skills/using-primitives/references/primitives/daemonset.md b/plugin/skills/using-primitives/references/primitives/daemonset.md index 8419d730..4d31a955 100644 --- a/plugin/skills/using-primitives/references/primitives/daemonset.md +++ b/plugin/skills/using-primitives/references/primitives/daemonset.md @@ -212,7 +212,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(readyNodes)` or read it -opportunistically with `WithOptionalData(readyNodes)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(readyNodes)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Suspension diff --git a/plugin/skills/using-primitives/references/primitives/deployment.md b/plugin/skills/using-primitives/references/primitives/deployment.md index b7b775b2..6929ad65 100644 --- a/plugin/skills/using-primitives/references/primitives/deployment.md +++ b/plugin/skills/using-primitives/references/primitives/deployment.md @@ -218,7 +218,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(revision)` or read it -opportunistically with `WithOptionalData(revision)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(revision)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Suspension diff --git a/plugin/skills/using-primitives/references/primitives/hpa.md b/plugin/skills/using-primitives/references/primitives/hpa.md index 85133ad2..2d376223 100644 --- a/plugin/skills/using-primitives/references/primitives/hpa.md +++ b/plugin/skills/using-primitives/references/primitives/hpa.md @@ -193,7 +193,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(currentReplicas)` or read it -opportunistically with `WithOptionalData(currentReplicas)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(currentReplicas)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Operational Status diff --git a/plugin/skills/using-primitives/references/primitives/job.md b/plugin/skills/using-primitives/references/primitives/job.md index 04aec209..76617a97 100644 --- a/plugin/skills/using-primitives/references/primitives/job.md +++ b/plugin/skills/using-primitives/references/primitives/job.md @@ -194,7 +194,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(succeeded)` or read it -opportunistically with `WithOptionalData(succeeded)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(succeeded)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). Guard on the Job's `Completable` status when a later resource must wait for the Job to finish. Data cells carry values between resources; they are not a substitute for the completion condition. diff --git a/plugin/skills/using-primitives/references/primitives/networkpolicy.md b/plugin/skills/using-primitives/references/primitives/networkpolicy.md index 62128187..45589829 100644 --- a/plugin/skills/using-primitives/references/primitives/networkpolicy.md +++ b/plugin/skills/using-primitives/references/primitives/networkpolicy.md @@ -185,7 +185,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(policyName)` or read it -opportunistically with `WithOptionalData(policyName)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(policyName)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Full Example diff --git a/plugin/skills/using-primitives/references/primitives/pdb.md b/plugin/skills/using-primitives/references/primitives/pdb.md index 6653eaf8..383c56c5 100644 --- a/plugin/skills/using-primitives/references/primitives/pdb.md +++ b/plugin/skills/using-primitives/references/primitives/pdb.md @@ -170,7 +170,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(expectedPods)` or read it -opportunistically with `WithOptionalData(expectedPods)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(expectedPods)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Full Example diff --git a/plugin/skills/using-primitives/references/primitives/pod.md b/plugin/skills/using-primitives/references/primitives/pod.md index 2d19f91d..39322c45 100644 --- a/plugin/skills/using-primitives/references/primitives/pod.md +++ b/plugin/skills/using-primitives/references/primitives/pod.md @@ -178,7 +178,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(agentIP)` or read it -opportunistically with `WithOptionalData(agentIP)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(agentIP)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Suspension diff --git a/plugin/skills/using-primitives/references/primitives/pv.md b/plugin/skills/using-primitives/references/primitives/pv.md index 0579d6b1..7b60a89b 100644 --- a/plugin/skills/using-primitives/references/primitives/pv.md +++ b/plugin/skills/using-primitives/references/primitives/pv.md @@ -170,7 +170,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(boundClaim)` or read it -opportunistically with `WithOptionalData(boundClaim)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(boundClaim)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Operational Status diff --git a/plugin/skills/using-primitives/references/primitives/replicaset.md b/plugin/skills/using-primitives/references/primitives/replicaset.md index aadd14a7..06656f8b 100644 --- a/plugin/skills/using-primitives/references/primitives/replicaset.md +++ b/plugin/skills/using-primitives/references/primitives/replicaset.md @@ -186,7 +186,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(readyReplicas)` or read it -opportunistically with `WithOptionalData(readyReplicas)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(readyReplicas)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Suspension diff --git a/plugin/skills/using-primitives/references/primitives/role.md b/plugin/skills/using-primitives/references/primitives/role.md index 575d1e67..c0d05ac0 100644 --- a/plugin/skills/using-primitives/references/primitives/role.md +++ b/plugin/skills/using-primitives/references/primitives/role.md @@ -170,7 +170,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(roleName)` or read it -opportunistically with `WithOptionalData(roleName)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(roleName)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Full Example diff --git a/plugin/skills/using-primitives/references/primitives/rolebinding.md b/plugin/skills/using-primitives/references/primitives/rolebinding.md index cc99af66..2610790f 100644 --- a/plugin/skills/using-primitives/references/primitives/rolebinding.md +++ b/plugin/skills/using-primitives/references/primitives/rolebinding.md @@ -173,7 +173,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(bindingName)` or read it -opportunistically with `WithOptionalData(bindingName)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(bindingName)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Full Example diff --git a/plugin/skills/using-primitives/references/primitives/secret.md b/plugin/skills/using-primitives/references/primitives/secret.md index 50c51df5..8e196e43 100644 --- a/plugin/skills/using-primitives/references/primitives/secret.md +++ b/plugin/skills/using-primitives/references/primitives/secret.md @@ -262,7 +262,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(password)` or read it -opportunistically with `WithOptionalData(password)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(password)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). A cell extracted from a Secret holds the decoded plaintext for the rest of the reconcile. Use it to build the object you are applying, and keep it out of conditions, events, and log lines. diff --git a/plugin/skills/using-primitives/references/primitives/service.md b/plugin/skills/using-primitives/references/primitives/service.md index 78b1a795..b8c60809 100644 --- a/plugin/skills/using-primitives/references/primitives/service.md +++ b/plugin/skills/using-primitives/references/primitives/service.md @@ -169,7 +169,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(clusterIP)` or read it -opportunistically with `WithOptionalData(clusterIP)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(clusterIP)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Operational Status diff --git a/plugin/skills/using-primitives/references/primitives/serviceaccount.md b/plugin/skills/using-primitives/references/primitives/serviceaccount.md index 992d5f27..08cc90e5 100644 --- a/plugin/skills/using-primitives/references/primitives/serviceaccount.md +++ b/plugin/skills/using-primitives/references/primitives/serviceaccount.md @@ -137,7 +137,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(saName)` or read it -opportunistically with `WithOptionalData(saName)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(saName)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Full Example diff --git a/plugin/skills/using-primitives/references/primitives/statefulset.md b/plugin/skills/using-primitives/references/primitives/statefulset.md index 32590804..b9d2eb7e 100644 --- a/plugin/skills/using-primitives/references/primitives/statefulset.md +++ b/plugin/skills/using-primitives/references/primitives/statefulset.md @@ -243,7 +243,8 @@ resource, err := builder.Build() ``` Resources registered later in the same component block on the cell with `WithDataGuard(currentRevision)` or read it -opportunistically with `WithOptionalData(currentRevision)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(currentRevision)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Suspension diff --git a/plugin/skills/using-primitives/references/primitives/unstructured.md b/plugin/skills/using-primitives/references/primitives/unstructured.md index a5ea96f8..84430f96 100644 --- a/plugin/skills/using-primitives/references/primitives/unstructured.md +++ b/plugin/skills/using-primitives/references/primitives/unstructured.md @@ -13,7 +13,8 @@ Choose between the three approaches in this order: to manage an external CRD without generating Go client code. You supply all lifecycle semantics through required handlers. 3. **Custom resource wrapper** (`pkg/generic`): use this when you own the Go type (your own CRD) or want a fully typed - mutation surface with a custom builder API. See the [Custom Resource Implementation Guide](../custom-resource.md). + mutation surface with a custom builder API. See the + [Custom Resource Implementation Guide](../../../custom-resource-wrappers/references/custom-resource.md). See also [Unstructured Primitives](../primitives.md#unstructured-primitives) in the Primitives Overview for a summary table and [Implementing a Custom Resource](../primitives.md#implementing-a-custom-resource) for the full walkthrough. @@ -253,7 +254,8 @@ static.ExtractInto(builder, providerIP, func(obj uns.Unstructured) (string, erro An absent field yields the zero value and the cell is still marked present, which a data guard treats as satisfied. Return an error from the function instead when a missing field should fail the reconcile and leave the cell unset. Resources registered later in the same component block on the cell with `WithDataGuard(providerIP)` or read it -opportunistically with `WithOptionalData(providerIP)`. See [Declared Data](../component.md#declared-data). +opportunistically with `WithOptionalData(providerIP)`. See +[Declared Data](../../../building-components/references/component.md#declared-data). ## Suspension Handlers @@ -346,4 +348,5 @@ operator author. Write table-driven tests covering all status transitions before **Use typed primitives or custom resource wrappers for your own CRDs.** Unstructured primitives are intended for third-party or generated resources where a Go type is unavailable. For your own CRDs, generate the Go type and use a -typed wrapper; see the [Custom Resource Implementation Guide](../custom-resource.md). +typed wrapper; see the +[Custom Resource Implementation Guide](../../../custom-resource-wrappers/references/custom-resource.md).