Skip to content

Commit 74ccdc9

Browse files
author
Tiny Systems
committed
install: cluster settings — ingress class, domain, storage, cert-manager issuer
Some modules need cluster-specific install values (ingress class, base domain, storage class, TLS issuer). These are properties of the CLUSTER, not per module, so set them once and every install inherits them. - --ingress-class / --domain / --storage-class / --cluster-issuer on 'tiny up' and 'tiny install'. Persisted as annotations on the tinysystems namespace (they travel with the cluster; no local dotfile). - provision.Settings loads/saves them; module installs read them and set managerIngress.ingress.{enabled,className}, global.defaultDomainSuffix, the cert-manager.io/(cluster-)issuer ingress annotation, and storage.{enabled,storageClassName,size} — gated by the catalog's per-module requires_ingress / requires_storage flags. - Agent install-on-the-fly inherits the same saved settings. Zero-config on kind/minikube (no ingress class → ingress stays off, port-forward). Verified live: flags persist to ns annotations; http-module got a class=nginx ingress with cert-manager.io/cluster-issuer=<name> (escaping correct); modules that don't need ingress didn't get one.
1 parent 05d5057 commit 74ccdc9

7 files changed

Lines changed: 221 additions & 9 deletions

File tree

cmd/commands.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,13 @@ cluster or add something specific.`,
5050
return err
5151
}
5252
brokerURL := provision.BrokerURL(ctx, cfg, flagNamespace)
53+
settings := resolveSettings(ctx, cfg)
5354

5455
fmt.Println()
5556
var release string
5657
if err := step(fmt.Sprintf("module: %s %s", m.FullName, styleSubtle.Render(m.Tag)), func() error {
5758
var e error
58-
release, e = hc.InstallModule(ctx, m, brokerURL)
59+
release, e = hc.InstallModule(ctx, m, brokerURL, settings)
5960
return e
6061
}); err != nil {
6162
fmt.Println(" " + styleSubtle.Render("fresh cluster? run `tiny up` first to install the runtime, then retry."))

cmd/root.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ var (
2121
flagNoRegister bool
2222
flagPrint bool
2323
flagProject string
24+
25+
// Cluster install settings — properties of the target cluster, applied to
26+
// module installs and persisted as tinysystems-namespace annotations.
27+
flagIngressClass string
28+
flagDomain string
29+
flagStorageClass string
30+
flagClusterIssuer string
2431
)
2532

2633
const defaultNamespace = "tinysystems"
@@ -49,6 +56,11 @@ Run with no command to start the dev server (MCP endpoint + editor).`,
4956
root.PersistentFlags().BoolVarP(&flagYes, "yes", "y", false, "skip the target confirmation prompt (for CI)")
5057
root.PersistentFlags().BoolVar(&flagNoRegister, "no-register", false, "don't auto-add the MCP endpoint to Claude Code on serve")
5158
root.PersistentFlags().StringVarP(&flagProject, "project", "p", "", "active project for this session (created if missing); scopes the MCP endpoint + editor")
59+
// Cluster install settings (persisted to the namespace; set once).
60+
root.PersistentFlags().StringVar(&flagIngressClass, "ingress-class", "", "ingress controller class for modules that expose HTTP (e.g. nginx)")
61+
root.PersistentFlags().StringVar(&flagDomain, "domain", "", "base domain suffix for module ingress hostnames")
62+
root.PersistentFlags().StringVar(&flagStorageClass, "storage-class", "", "storage class for modules that need a PVC")
63+
root.PersistentFlags().StringVar(&flagClusterIssuer, "cluster-issuer", "", "cert-manager ClusterIssuer name to annotate ingresses for TLS")
5264
// --print is local to bare `tiny` (the serve command): dump the client
5365
// config and exit instead of serving.
5466
root.Flags().BoolVar(&flagPrint, "print", false, "print the MCP client config and exit (don't serve)")

cmd/up.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package cmd
22

33
import (
4+
"context"
45
"fmt"
56
"os"
67
"time"
78

89
"github.com/mattn/go-isatty"
910
"github.com/spf13/cobra"
11+
"k8s.io/client-go/rest"
1012

1113
"github.com/tiny-systems/tiny/internal/catalog"
1214
"github.com/tiny-systems/tiny/internal/kube"
@@ -73,6 +75,7 @@ func runUp(cmd *cobra.Command, _ []string) error {
7375
// Resolve the authenticated broker URL now that NATS is up, and wire it
7476
// into every module so durable execution works on the first run.
7577
brokerURL := provision.BrokerURL(ctx, cfg, flagNamespace)
78+
settings := resolveSettings(ctx, cfg)
7679

7780
for _, name := range coreModules {
7881
nm := name
@@ -81,7 +84,7 @@ func runUp(cmd *cobra.Command, _ []string) error {
8184
if err != nil {
8285
return err
8386
}
84-
_, err = hc.InstallModule(ctx, m, brokerURL)
87+
_, err = hc.InstallModule(ctx, m, brokerURL, settings)
8588
return err
8689
}); err != nil {
8790
return err
@@ -143,3 +146,30 @@ func step(label string, fn func() error) error {
143146
func elapsed(start time.Time) string {
144147
return time.Since(start).Round(time.Second).String()
145148
}
149+
150+
// flagSettings collects the cluster-install flags passed this invocation.
151+
func flagSettings() provision.Settings {
152+
s := provision.Settings{
153+
IngressClass: flagIngressClass,
154+
DomainSuffix: flagDomain,
155+
StorageClass: flagStorageClass,
156+
}
157+
if flagClusterIssuer != "" {
158+
s.Issuer = flagClusterIssuer
159+
s.ClusterIssuer = true
160+
}
161+
return s
162+
}
163+
164+
// resolveSettings returns the effective cluster settings for a module install:
165+
// the namespace's saved settings overlaid with any flags passed this run — and
166+
// persists those flags so later installs (including the agent's on-the-fly
167+
// ones) inherit them. Set them once with `tiny up --ingress-class … …`.
168+
func resolveSettings(ctx context.Context, cfg *rest.Config) provision.Settings {
169+
saved, _ := provision.LoadSettings(ctx, cfg, flagNamespace)
170+
fs := flagSettings()
171+
if !fs.Empty() {
172+
_ = provision.SaveSettings(ctx, cfg, flagNamespace, fs)
173+
}
174+
return saved.Merge(fs)
175+
}

internal/catalog/catalog.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ type Module struct {
3535
// services, deployments, ingresses) for modules that manage cluster
3636
// resources — http-module's port exposure, kubernetes-module's ops.
3737
RequiresKubernetesAccess bool
38+
// RequiresIngress / RequiresStorage tell us whether this module needs the
39+
// cluster's ingress class / storage class wired at install time.
40+
RequiresIngress bool
41+
RequiresStorage bool
3842
}
3943

4044
// apiModule mirrors the fields we consume from GET /v1/modules/{name}.
@@ -48,6 +52,10 @@ type apiModule struct {
4852
Tag string `json:"tag"`
4953
RequiresKubernetesAccess bool `json:"requires_kubernetes_access"`
5054
} `json:"latest_version"`
55+
HelmInstall struct {
56+
RequiresIngress bool `json:"requires_ingress"`
57+
RequiresStorage bool `json:"requires_storage"`
58+
} `json:"helm_install"`
5159
}
5260

5361
// Resolve looks up a module by name against the public catalog. Public
@@ -118,6 +126,8 @@ func fetch(ctx context.Context, baseURL, name string) (*Module, error) {
118126
Repo: am.LatestVersion.Repo,
119127
Tag: am.LatestVersion.Tag,
120128
RequiresKubernetesAccess: am.LatestVersion.RequiresKubernetesAccess,
129+
RequiresIngress: am.HelmInstall.RequiresIngress,
130+
RequiresStorage: am.HelmInstall.RequiresStorage,
121131
}, nil
122132
}
123133

internal/installer/installer.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,12 @@ func (m *ModuleInstaller) InstallModule(ctx context.Context, moduleName, version
6565
return &sdktools.InstallResult{Success: false, Error: fmt.Sprintf("helm client: %v", err)}, nil
6666
}
6767
broker := provision.BrokerURL(ctx, m.cfg, m.namespace)
68+
// Inherit the cluster's saved install settings (ingress/storage/issuer)
69+
// so an agent-installed module lands with the same config as `tiny install`.
70+
settings, _ := provision.LoadSettings(ctx, m.cfg, m.namespace)
6871

6972
progress("install", "installing "+mod.FullName+" ("+mod.Tag+") — this can take a minute while the image pulls", "info")
70-
release, err := hc.InstallModule(ctx, mod, broker)
73+
release, err := hc.InstallModule(ctx, mod, broker, settings)
7174
if err != nil {
7275
return &sdktools.InstallResult{Success: false, Error: err.Error()}, nil
7376
}

internal/provision/provision.go

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ func (c *Client) InstallOTEL(ctx context.Context) error {
190190
// release parameterised by the module's image. Returns the helm release
191191
// name. natsURL wires the broker so durable execution is on out of the box;
192192
// pass "" to leave the module in blocking-only mode.
193-
func (c *Client) InstallModule(ctx context.Context, m *catalog.Module, natsURL string) (string, error) {
193+
func (c *Client) InstallModule(ctx context.Context, m *catalog.Module, natsURL string, settings Settings) (string, error) {
194194
release := SanitizeResourceName(m.FullName)
195195
spec := &helmclient.ChartSpec{
196196
ReleaseName: release,
@@ -204,7 +204,7 @@ func (c *Client) InstallModule(ctx context.Context, m *catalog.Module, natsURL s
204204
Force: true,
205205
Replace: true,
206206
CleanupOnFail: true,
207-
ValuesOptions: values.Options{Values: c.moduleValues(m, release, natsURL)},
207+
ValuesOptions: values.Options{Values: c.moduleValues(m, release, natsURL, settings)},
208208
}
209209
if err := c.install(ctx, spec); err != nil {
210210
return "", err
@@ -218,7 +218,7 @@ func (c *Client) InstallModule(ctx context.Context, m *catalog.Module, natsURL s
218218
// otherwise), the durable-transport env, secret resolution, and the broker
219219
// URL. --name is the workspace-qualified full name so the node IDs the agent
220220
// builds resolve to this operator.
221-
func (c *Client) moduleValues(m *catalog.Module, release, natsURL string) []string {
221+
func (c *Client) moduleValues(m *catalog.Module, release, natsURL string, settings Settings) []string {
222222
v := []string{
223223
"controllerManager.manager.image.repository=" + m.Repo,
224224
"controllerManager.manager.image.tag=" + m.Tag,
@@ -242,9 +242,6 @@ func (c *Client) moduleValues(m *catalog.Module, release, natsURL string) []stri
242242
// uses the WorkQueue stream (pod-death survival + per-edge retry).
243243
"controllerManager.manager.extraEnv[1].name=TINY_NATS_TRANSPORT",
244244
"controllerManager.manager.extraEnv[1].value=jetstream",
245-
// Local installs don't assume an ingress controller; http servers are
246-
// reachable by port-forward.
247-
"managerIngress.ingress.enabled=false",
248245
// Namespace-scoped secret reads so [[secret:name/key]] placeholders in
249246
// node settings resolve against Kubernetes Secrets.
250247
"secrets.enabled=true",
@@ -255,6 +252,40 @@ func (c *Client) moduleValues(m *catalog.Module, release, natsURL string) []stri
255252
if m.RequiresKubernetesAccess {
256253
v = append(v, "rbac.enableKubernetesResourceAccess=true")
257254
}
255+
256+
// Ingress: enable only when the module exposes HTTP and the cluster has an
257+
// ingress class set. Otherwise leave it off (reachable by port-forward,
258+
// the default on kind/minikube).
259+
if m.RequiresIngress && settings.IngressClass != "" {
260+
v = append(v,
261+
"managerIngress.ingress.enabled=true",
262+
"managerIngress.ingress.className="+settings.IngressClass,
263+
)
264+
if settings.DomainSuffix != "" {
265+
v = append(v, "global.defaultDomainSuffix="+settings.DomainSuffix)
266+
}
267+
if settings.Issuer != "" {
268+
// cert-manager TLS: annotate with the (cluster-)issuer. Dots in the
269+
// annotation key are escaped so helm --set treats them as key, not
270+
// nesting.
271+
key := "cert-manager\\.io/issuer"
272+
if settings.ClusterIssuer {
273+
key = "cert-manager\\.io/cluster-issuer"
274+
}
275+
v = append(v, "managerIngress.ingress.annotations."+key+"="+settings.Issuer)
276+
}
277+
} else {
278+
v = append(v, "managerIngress.ingress.enabled=false")
279+
}
280+
281+
// Storage: wire the cluster's storage class for modules that need a PVC.
282+
if m.RequiresStorage && settings.StorageClass != "" {
283+
v = append(v,
284+
"storage.enabled=true",
285+
"storage.storageClassName="+settings.StorageClass,
286+
"storage.size="+settings.storageSizeOr(),
287+
)
288+
}
258289
return v
259290
}
260291

internal/provision/settings.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package provision
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
7+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
8+
"k8s.io/apimachinery/pkg/types"
9+
"k8s.io/client-go/kubernetes"
10+
"k8s.io/client-go/rest"
11+
)
12+
13+
// Settings are cluster-wide install values — properties of YOUR cluster, not
14+
// of any one module: which ingress controller, the base domain, the storage
15+
// class. They're persisted as annotations on the tinysystems namespace, so
16+
// they travel with the cluster and every module install reads them. Set them
17+
// once (flags on `tiny up`/`tiny install`); modules that need them pick them up.
18+
type Settings struct {
19+
IngressClass string
20+
DomainSuffix string
21+
StorageClass string
22+
StorageSize string // default 1Gi when a module needs storage
23+
// Issuer is the cert-manager (Cluster)Issuer name to annotate ingresses
24+
// with for TLS; ClusterIssuer selects cluster-issuer vs namespace issuer.
25+
Issuer string
26+
ClusterIssuer bool
27+
}
28+
29+
const (
30+
annIngressClass = "tinysystems.io/ingress-class"
31+
annDomainSuffix = "tinysystems.io/domain-suffix"
32+
annStorageClass = "tinysystems.io/storage-class"
33+
annStorageSize = "tinysystems.io/storage-size"
34+
annIssuer = "tinysystems.io/issuer"
35+
annClusterIssuer = "tinysystems.io/issuer-cluster-scoped"
36+
)
37+
38+
func (s Settings) storageSizeOr() string {
39+
if s.StorageSize == "" {
40+
return "1Gi"
41+
}
42+
return s.StorageSize
43+
}
44+
45+
// Empty reports whether no setting is present.
46+
func (s Settings) Empty() bool { return s == Settings{} }
47+
48+
// Merge overlays o's non-empty fields onto s (o wins). Used to layer
49+
// this-invocation flags over the cluster's saved settings.
50+
func (s Settings) Merge(o Settings) Settings {
51+
if o.IngressClass != "" {
52+
s.IngressClass = o.IngressClass
53+
}
54+
if o.DomainSuffix != "" {
55+
s.DomainSuffix = o.DomainSuffix
56+
}
57+
if o.StorageClass != "" {
58+
s.StorageClass = o.StorageClass
59+
}
60+
if o.StorageSize != "" {
61+
s.StorageSize = o.StorageSize
62+
}
63+
if o.Issuer != "" {
64+
s.Issuer = o.Issuer
65+
s.ClusterIssuer = o.ClusterIssuer
66+
}
67+
return s
68+
}
69+
70+
// LoadSettings reads the saved cluster settings off the namespace annotations.
71+
func LoadSettings(ctx context.Context, cfg *rest.Config, namespace string) (Settings, error) {
72+
cs, err := kubernetes.NewForConfig(cfg)
73+
if err != nil {
74+
return Settings{}, err
75+
}
76+
ns, err := cs.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{})
77+
if err != nil {
78+
return Settings{}, err
79+
}
80+
a := ns.Annotations
81+
return Settings{
82+
IngressClass: a[annIngressClass],
83+
DomainSuffix: a[annDomainSuffix],
84+
StorageClass: a[annStorageClass],
85+
StorageSize: a[annStorageSize],
86+
Issuer: a[annIssuer],
87+
ClusterIssuer: a[annClusterIssuer] == "true",
88+
}, nil
89+
}
90+
91+
// SaveSettings persists the non-empty settings as namespace annotations
92+
// (a merge patch — it never clears an existing annotation).
93+
func SaveSettings(ctx context.Context, cfg *rest.Config, namespace string, s Settings) error {
94+
ann := map[string]string{}
95+
if s.IngressClass != "" {
96+
ann[annIngressClass] = s.IngressClass
97+
}
98+
if s.DomainSuffix != "" {
99+
ann[annDomainSuffix] = s.DomainSuffix
100+
}
101+
if s.StorageClass != "" {
102+
ann[annStorageClass] = s.StorageClass
103+
}
104+
if s.StorageSize != "" {
105+
ann[annStorageSize] = s.StorageSize
106+
}
107+
if s.Issuer != "" {
108+
ann[annIssuer] = s.Issuer
109+
if s.ClusterIssuer {
110+
ann[annClusterIssuer] = "true"
111+
}
112+
}
113+
if len(ann) == 0 {
114+
return nil
115+
}
116+
cs, err := kubernetes.NewForConfig(cfg)
117+
if err != nil {
118+
return err
119+
}
120+
patch, _ := json.Marshal(map[string]interface{}{
121+
"metadata": map[string]interface{}{"annotations": ann},
122+
})
123+
_, err = cs.CoreV1().Namespaces().Patch(ctx, namespace, types.MergePatchType, patch, metav1.PatchOptions{})
124+
return err
125+
}

0 commit comments

Comments
 (0)