Skip to content

Commit e665a0b

Browse files
committed
refactor(starters): render the starter workspaces from Go instead of shell
Replaces scripts/gen-starters.sh with cmd/starters. Output is byte-identical to the shell version; the difference is what can go wrong and when it is caught. The shell script located values by line prefix, indentation included: ' email: ' ' url: ' ' path: ' That cannot distinguish the GitOps path: from any other four-space path: key, so the script needed a guard requiring each prefix to match exactly one line, and a restructure of examples/ was only caught once CI had built nic and rendered a starter. Resolving each field as a YAML path instead ($.repository.existing.path) removes the ambiguity rather than defending against it: a path either resolves or it fails, naming itself. Three pieces of machinery go away with it - the exactly-one-match guard, the sed dance that reattached a trailing comment to a replaced line, and the hardcoded indentation. Comments and blank lines still survive, which was the one argument for staying in shell. The edit is applied to the source text rather than by marshalling the parsed document back out: each value's token gives the line and column where the value starts, so rewriting from that column leaves the rest of the line - including a trailing comment - untouched. goccy/go-yaml is already the parser on both sides of this: #603 made it the config parser and #583's placeholder gate walks its AST. Block scalars are rejected explicitly. Their token is the |/> indicator rather than the body, so a newline check never fires and an in-place edit would leave the following lines orphaned. The tests are the point of the move: declared fields are resolved against the real examples/*.yaml at go test time, so a renamed key fails on a laptop instead of in CI, and the same-named-sibling case that the line-prefix match got wrong is pinned directly.
1 parent 862395d commit e665a0b

6 files changed

Lines changed: 453 additions & 136 deletions

File tree

‎.github/workflows/starters.yml‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ on:
44
pull_request:
55
paths:
66
- "starters/**"
7-
- "scripts/gen-starters.sh"
7+
- "cmd/starters/**"
88
- "examples/local-config.yaml"
99
- "examples/aws-config.yaml"
1010
- ".github/workflows/starters.yml"
1111
push:
1212
branches: [ main ]
1313
paths:
1414
- "starters/**"
15-
- "scripts/gen-starters.sh"
15+
- "cmd/starters/**"
1616
- "examples/local-config.yaml"
1717
- "examples/aws-config.yaml"
1818
- ".github/workflows/starters.yml"
@@ -59,7 +59,7 @@ jobs:
5959
shell: bash
6060
run: |
6161
set -euo pipefail
62-
./scripts/gen-starters.sh dist/starters
62+
go run ./cmd/starters -out dist/starters
6363
6464
- name: Rendered starters are complete
6565
shell: bash
@@ -131,7 +131,7 @@ jobs:
131131
needs: validate-starters
132132
# Tag builds only, and deliberately NOT workflow_dispatch. A dispatch can
133133
# target any ref: from a branch it would publish starter-*:vmain (and pin a
134-
# version from the PREVIOUS tag, since gen-starters.sh reads git describe),
134+
# version from the PREVIOUS tag, since cmd/starters reads git describe),
135135
# and from an existing tag it would overwrite a released bundle - the exact
136136
# rewrite the trigger comment above says must never happen. Deployment-branch
137137
# rules live in repo settings and cannot be reviewed from this file, so the
@@ -188,7 +188,7 @@ jobs:
188188
shell: bash
189189
run: |
190190
set -euo pipefail
191-
./scripts/gen-starters.sh dist/starters
191+
go run ./cmd/starters -out dist/starters
192192
193193
- name: Configure quay registry
194194
shell: bash

‎Makefile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ docs: ## Generate CLI and configuration reference documentation
2929

3030
starters: ## Generate the Nebi starter workspaces into dist/starters
3131
@echo "Generating starters..."
32-
./scripts/gen-starters.sh dist/starters
32+
go run ./cmd/starters -out dist/starters
3333

3434
build-all: ## Build binaries for all platforms
3535
@echo "Building for all platforms..."

‎cmd/starters/main.go‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Command starters renders the Nebi starter workspaces from examples/.
2+
//
3+
// A starter is the provider's example config with the identity-bearing values
4+
// replaced by the CHANGEME sentinel, plus the pixi workspace that pins the
5+
// toolchain and the provider's README. examples/ stays the single source of
6+
// truth for config content, so there is no second copy to drift.
7+
//
8+
// Output is published as OCI bundles and deliberately not committed; see
9+
// .github/workflows/starters.yml.
10+
//
11+
// Usage:
12+
//
13+
// go run ./cmd/starters -out dist/starters # or: make starters
14+
package main
15+
16+
import (
17+
"flag"
18+
"fmt"
19+
"log"
20+
"os"
21+
"os/exec"
22+
"path/filepath"
23+
"strings"
24+
)
25+
26+
func main() {
27+
outDir := flag.String("out", "dist/starters", "Output directory for the rendered starter workspaces")
28+
nicVersion := flag.String("version", "", "nic version the starters pin (default: the most recent git tag, minus the leading v)")
29+
templates := flag.String("templates", "starters/templates", "Directory holding pixi.toml.tmpl and the per-provider READMEs")
30+
examples := flag.String("examples", "examples", "Directory holding <provider>-config.yaml")
31+
flag.Parse()
32+
33+
version := *nicVersion
34+
if version == "" {
35+
v, err := latestTag()
36+
if err != nil {
37+
log.Fatalf("could not determine the nic version to pin: %v; pass -version", err)
38+
}
39+
version = v
40+
}
41+
42+
if err := generate(*outDir, *templates, *examples, version); err != nil {
43+
log.Fatalf("%v", err)
44+
}
45+
}
46+
47+
// latestTag returns the most recent tag with its leading v stripped. On a tag
48+
// build this is that tag; elsewhere it is the previous one, which is why the
49+
// publish workflow is tag-only.
50+
func latestTag() (string, error) {
51+
out, err := exec.Command("git", "describe", "--tags", "--abbrev=0").Output()
52+
if err != nil {
53+
return "", fmt.Errorf("git describe: %w", err)
54+
}
55+
tag := strings.TrimSpace(string(out))
56+
if tag == "" {
57+
return "", fmt.Errorf("git describe returned nothing")
58+
}
59+
return strings.TrimPrefix(tag, "v"), nil
60+
}
61+
62+
// generate renders every provider in scope. Failures accumulate: a restructure
63+
// of examples/ usually moves more than one key, and reporting them one run at
64+
// a time makes the author rediscover the same problem repeatedly.
65+
func generate(outDir, templates, examples, version string) error {
66+
pixiTmpl, err := os.ReadFile(filepath.Join(templates, "pixi.toml.tmpl"))
67+
if err != nil {
68+
return fmt.Errorf("read pixi template: %w", err)
69+
}
70+
71+
var problems []string
72+
for _, name := range providerNames() {
73+
if err := generateOne(outDir, templates, examples, name, version, pixiTmpl); err != nil {
74+
problems = append(problems, fmt.Sprintf("%s: %v", name, err))
75+
continue
76+
}
77+
fmt.Printf("generated %s (nic %s)\n", filepath.Join(outDir, name), version)
78+
}
79+
80+
if len(problems) > 0 {
81+
return fmt.Errorf("starter generation failed:\n - %s", strings.Join(problems, "\n - "))
82+
}
83+
return nil
84+
}
85+
86+
func generateOne(outDir, templates, examples, name, version string, pixiTmpl []byte) error {
87+
p := providers[name]
88+
89+
src, err := os.ReadFile(filepath.Join(examples, name+"-config.yaml"))
90+
if err != nil {
91+
return fmt.Errorf("read example: %w", err)
92+
}
93+
94+
config, err := placeholderConfig(src, p.fields)
95+
if err != nil {
96+
return err
97+
}
98+
99+
pixi, err := renderPixi(pixiTmpl, name, version, p.deps)
100+
if err != nil {
101+
return err
102+
}
103+
104+
readme, err := os.ReadFile(filepath.Join(templates, "README."+name+".md"))
105+
if err != nil {
106+
return fmt.Errorf("read README: %w", err)
107+
}
108+
109+
dest := filepath.Join(outDir, name)
110+
if err := os.MkdirAll(dest, 0o750); err != nil {
111+
return fmt.Errorf("create %s: %w", dest, err)
112+
}
113+
for file, content := range map[string][]byte{
114+
"config.yaml": config,
115+
"pixi.toml": pixi,
116+
"README.md": readme,
117+
} {
118+
if err := os.WriteFile(filepath.Join(dest, file), content, 0o600); err != nil {
119+
return fmt.Errorf("write %s: %w", file, err)
120+
}
121+
}
122+
return nil
123+
}

‎cmd/starters/starters.go‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"strings"
7+
8+
"github.com/goccy/go-yaml"
9+
"github.com/goccy/go-yaml/parser"
10+
"github.com/goccy/go-yaml/token"
11+
)
12+
13+
// placeholder is the sentinel a starter ships instead of an identity-bearing
14+
// value. nic's validation rejects any config still containing it, so an
15+
// unedited starter cannot be deployed by accident; see
16+
// docs/operations/config-placeholders.md.
17+
const placeholder = "CHANGEME"
18+
19+
// provider describes one starter: which config values the reader must supply,
20+
// and any conda dependency the provider needs beyond nic itself.
21+
type provider struct {
22+
// fields are YAML paths, not line prefixes. A path either resolves to a
23+
// value or it does not, so a key that moves, gets renamed, or gains a
24+
// same-named sibling at another level is a hard error naming the path
25+
// rather than something a text match can silently get wrong.
26+
fields []string
27+
deps string
28+
}
29+
30+
// providers is the set in scope. Extend deliberately: a new entry needs its
31+
// own placeholder paths, and generate refuses to emit a starter that declares
32+
// none rather than shipping one with every real value intact.
33+
var providers = map[string]provider{
34+
"local": {
35+
// kind runs everything locally: the certificate is self-signed and the
36+
// GitOps repo is created for the user, so only the name is theirs.
37+
fields: []string{"$.project_name"},
38+
// The local provider drives kind through an embedded Go library, so
39+
// there is no OpenTofu in this workspace.
40+
deps: "",
41+
},
42+
"aws": {
43+
fields: []string{
44+
"$.project_name",
45+
"$.domain",
46+
"$.certificate.acme.email",
47+
"$.repository.existing.url",
48+
"$.repository.existing.path",
49+
},
50+
// Pinning OpenTofu is the point of a pinned toolchain: without it nic
51+
// downloads an unpinned tofu at deploy time. The floor has to clear
52+
// pkg/tofu.MinVersion - below that nic rejects the PATH binary and
53+
// downloads one anyway, silently, which defeats the pin.
54+
deps: `opentofu = ">=1.11.3,<2"`,
55+
},
56+
}
57+
58+
// providerNames returns the providers in scope, sorted, so output ordering is
59+
// deterministic regardless of map iteration order.
60+
func providerNames() []string {
61+
names := make([]string, 0, len(providers))
62+
for name := range providers {
63+
names = append(names, name)
64+
}
65+
sort.Strings(names)
66+
return names
67+
}
68+
69+
// placeholderConfig rewrites src so that every path in fields carries the
70+
// placeholder instead of its real value, and returns the result.
71+
//
72+
// The edit is done on the source text rather than by marshalling the parsed
73+
// document back out: a round trip would reformat the file and drop the inline
74+
// comments that make the examples worth shipping. Each value's token gives the
75+
// line and the column where the value starts, so replacing from that column to
76+
// the end of the line touches nothing else, and a trailing comment on the same
77+
// line is reattached - those lines are exactly the ones whose hint the reader
78+
// needs most.
79+
func placeholderConfig(src []byte, fields []string) ([]byte, error) {
80+
if len(fields) == 0 {
81+
return nil, fmt.Errorf("no placeholder fields declared")
82+
}
83+
84+
file, err := parser.ParseBytes(src, parser.ParseComments)
85+
if err != nil {
86+
return nil, fmt.Errorf("parse config: %w", err)
87+
}
88+
89+
// Trailing newline handling: strings.Split on a file ending in "\n" yields
90+
// a final empty element, which Join restores, so the byte count is stable.
91+
lines := strings.Split(string(src), "\n")
92+
93+
for _, field := range fields {
94+
path, err := yaml.PathString(field)
95+
if err != nil {
96+
return nil, fmt.Errorf("%s is not a valid YAML path: %w", field, err)
97+
}
98+
99+
node, err := path.FilterFile(file)
100+
if err != nil {
101+
return nil, fmt.Errorf("%s resolved to nothing; the example has probably been restructured: %w", field, err)
102+
}
103+
104+
tok := node.GetToken()
105+
if tok == nil {
106+
return nil, fmt.Errorf("%s has no source position", field)
107+
}
108+
// A block scalar's token is the |/> indicator, not the body, so the
109+
// value spans lines the edit below would leave orphaned. Reject it by
110+
// type: checking the token text for a newline never fires here.
111+
if tok.Type == token.LiteralType || tok.Type == token.FoldedType || strings.Contains(tok.Value, "\n") {
112+
return nil, fmt.Errorf("%s is a multi-line value; only single-line scalars can be placeholdered in place", field)
113+
}
114+
115+
lineNo, col := tok.Position.Line, tok.Position.Column
116+
if lineNo < 1 || lineNo > len(lines) {
117+
return nil, fmt.Errorf("%s reports line %d, outside the file", field, lineNo)
118+
}
119+
line := lines[lineNo-1]
120+
if col < 1 || col > len(line)+1 {
121+
return nil, fmt.Errorf("%s reports column %d, outside line %d", field, col, lineNo)
122+
}
123+
124+
rebuilt := line[:col-1] + placeholder
125+
if c := node.GetComment(); c != nil {
126+
if text := strings.TrimSpace(c.String()); text != "" {
127+
rebuilt += " " + text
128+
}
129+
}
130+
lines[lineNo-1] = rebuilt
131+
}
132+
133+
out := strings.Join(lines, "\n")
134+
135+
// Re-parse rather than trust the edit. A starter that no longer loads
136+
// would still be "rejected" by nic validate, just for the wrong reason,
137+
// and that failure is easy to mistake for the placeholder gate working.
138+
if _, err := parser.ParseBytes([]byte(out), parser.ParseComments); err != nil {
139+
return nil, fmt.Errorf("placeholdered config no longer parses: %w", err)
140+
}
141+
return []byte(out), nil
142+
}
143+
144+
// renderPixi substitutes the workspace template's tokens. Kept as plain text
145+
// replacement because the template is a fixed file in this repo with three
146+
// tokens, not user input.
147+
func renderPixi(tmpl []byte, name, nicVersion, deps string) ([]byte, error) {
148+
out := string(tmpl)
149+
for token, value := range map[string]string{
150+
"__PROVIDER__": name,
151+
"__NIC_VERSION__": nicVersion,
152+
"__PROVIDER_DEPS__": deps,
153+
} {
154+
out = strings.ReplaceAll(out, token, value)
155+
}
156+
if i := strings.Index(out, "__"); i != -1 {
157+
return nil, fmt.Errorf("unsubstituted template token near %q", out[i:min(i+40, len(out))])
158+
}
159+
return []byte(out), nil
160+
}

0 commit comments

Comments
 (0)