diff --git a/dispatch/dispatch.go b/dispatch/dispatch.go new file mode 100644 index 0000000..feafccc --- /dev/null +++ b/dispatch/dispatch.go @@ -0,0 +1,190 @@ +// Package dispatch routes a rendered deployment source to the appropriate +// provider operation: container services go through the core Provider +// lifecycle, while helm, manifests, and argocd sources are delegated to the +// provider's optional engine interfaces (gated by capability). It is the +// seam that lets one provisioning path serve every source type. +package dispatch + +import ( + "context" + "fmt" + + ctrlplane "github.com/xraph/ctrlplane" + "github.com/xraph/ctrlplane/id" + "github.com/xraph/ctrlplane/provider" +) + +// Request carries everything needed to provision an instance from a +// rendered source, independent of source type. +type Request struct { + InstanceID id.ID + TenantID string + Name string + Namespace string + Kind provider.WorkloadKind + Source provider.RenderedSource + Labels map[string]string +} + +// Provision routes a rendered source to the right provider engine. Services +// use the core Provision; other types require the provider to implement the +// matching engine interface and advertise its capability, else +// ctrlplane.ErrUnsupportedSource. +func Provision(ctx context.Context, p provider.Provider, req Request) (*provider.ProvisionResult, error) { + switch req.Source.Type { + case provider.SourceServices: + return p.Provision(ctx, provider.ProvisionRequest{ + InstanceID: req.InstanceID, + TenantID: req.TenantID, + Name: req.Name, + Kind: req.Kind, + Services: req.Source.Services, + Labels: req.Labels, + }) + case provider.SourceManifests: + eng, ok := manifestEngine(p) + if !ok { + return nil, unsupported(req.Source.Type) + } + + var docs provider.RenderedManifests + if req.Source.Manifests != nil { + docs = *req.Source.Manifests + } + + return eng.ApplyManifests(ctx, provider.ManifestApplyRequest{ + InstanceID: req.InstanceID, + TenantID: req.TenantID, + Namespace: req.Namespace, + Manifests: docs, + Labels: req.Labels, + }) + case provider.SourceHelm: + eng, ok := helmEngine(p) + if !ok { + return nil, unsupported(req.Source.Type) + } + + var chart provider.RenderedHelm + if req.Source.Helm != nil { + chart = *req.Source.Helm + } + + return eng.HelmInstall(ctx, provider.HelmInstallRequest{ + InstanceID: req.InstanceID, + TenantID: req.TenantID, + Namespace: req.Namespace, + Chart: chart, + }) + case provider.SourceArgoCD: + eng, ok := argoEngine(p) + if !ok { + return nil, unsupported(req.Source.Type) + } + + var app provider.ArgoCDSource + if req.Source.ArgoCD != nil { + app = *req.Source.ArgoCD + } + + return eng.ArgoApply(ctx, provider.ArgoApplyRequest{ + InstanceID: req.InstanceID, + TenantID: req.TenantID, + App: app, + Labels: req.Labels, + }) + default: + return nil, fmt.Errorf("%w: %q", ctrlplane.ErrInvalidSource, req.Source.Type) + } +} + +// Deprovision routes teardown by source type. An empty sourceType (legacy +// instances that predate Source) is treated as services. +func Deprovision(ctx context.Context, p provider.Provider, sourceType provider.SourceType, instanceID id.ID) error { + switch sourceType { + case provider.SourceServices, "": + return p.Deprovision(ctx, instanceID) + case provider.SourceManifests: + eng, ok := manifestEngine(p) + if !ok { + return unsupported(sourceType) + } + + return eng.DeleteManifests(ctx, instanceID) + case provider.SourceHelm: + eng, ok := helmEngine(p) + if !ok { + return unsupported(sourceType) + } + + return eng.HelmUninstall(ctx, instanceID) + case provider.SourceArgoCD: + eng, ok := argoEngine(p) + if !ok { + return unsupported(sourceType) + } + + return eng.ArgoDelete(ctx, instanceID) + default: + return fmt.Errorf("%w: %q", ctrlplane.ErrInvalidSource, sourceType) + } +} + +// Status routes a status read by source type. An empty sourceType is treated +// as services. +func Status(ctx context.Context, p provider.Provider, sourceType provider.SourceType, instanceID id.ID) (*provider.InstanceStatus, error) { + switch sourceType { + case provider.SourceServices, "": + return p.Status(ctx, instanceID) + case provider.SourceManifests: + eng, ok := manifestEngine(p) + if !ok { + return nil, unsupported(sourceType) + } + + return eng.ManifestStatus(ctx, instanceID) + case provider.SourceHelm: + eng, ok := helmEngine(p) + if !ok { + return nil, unsupported(sourceType) + } + + return eng.HelmStatus(ctx, instanceID) + case provider.SourceArgoCD: + eng, ok := argoEngine(p) + if !ok { + return nil, unsupported(sourceType) + } + + return eng.ArgoStatus(ctx, instanceID) + default: + return nil, fmt.Errorf("%w: %q", ctrlplane.ErrInvalidSource, sourceType) + } +} + +// unsupported builds the capability-gated error. +func unsupported(t provider.SourceType) error { + return fmt.Errorf("%w: %q", ctrlplane.ErrUnsupportedSource, t) +} + +// manifestEngine returns the provider as a ManifestEngine when it both +// implements the interface and advertises the capability. +func manifestEngine(p provider.Provider) (provider.ManifestEngine, bool) { + eng, ok := p.(provider.ManifestEngine) + + return eng, ok && provider.HasCapability(p, provider.CapManifests) +} + +// helmEngine returns the provider as a HelmEngine when supported. +func helmEngine(p provider.Provider) (provider.HelmEngine, bool) { + eng, ok := p.(provider.HelmEngine) + + return eng, ok && provider.HasCapability(p, provider.CapHelm) +} + +// argoEngine returns the provider as an ArgoEngine when supported. +func argoEngine(p provider.Provider) (provider.ArgoEngine, bool) { + eng, ok := p.(provider.ArgoEngine) + + return eng, ok && provider.HasCapability(p, provider.CapArgoCD) +} diff --git a/dispatch/dispatch_test.go b/dispatch/dispatch_test.go new file mode 100644 index 0000000..5478bc1 --- /dev/null +++ b/dispatch/dispatch_test.go @@ -0,0 +1,225 @@ +package dispatch + +import ( + "context" + "errors" + "io" + "testing" + + ctrlplane "github.com/xraph/ctrlplane" + "github.com/xraph/ctrlplane/id" + "github.com/xraph/ctrlplane/provider" +) + +// fakeProvider implements provider.Provider plus all three engine +// interfaces, recording the last engine method invoked. Its advertised +// capabilities are configurable so tests can withhold a capability. +type fakeProvider struct { + caps []provider.Capability + called string +} + +func (f *fakeProvider) Info() provider.ProviderInfo { return provider.ProviderInfo{Name: "fake"} } +func (f *fakeProvider) Capabilities() []provider.Capability { return f.caps } + +func (f *fakeProvider) Provision(context.Context, provider.ProvisionRequest) (*provider.ProvisionResult, error) { + f.called = "Provision" + + return &provider.ProvisionResult{ProviderRef: "fake"}, nil +} + +func (f *fakeProvider) Deprovision(context.Context, id.ID) error { + f.called = "Deprovision" + + return nil +} +func (f *fakeProvider) Start(context.Context, id.ID) error { return nil } +func (f *fakeProvider) Stop(context.Context, id.ID) error { return nil } +func (f *fakeProvider) Restart(context.Context, id.ID) error { return nil } + +func (f *fakeProvider) Status(context.Context, id.ID) (*provider.InstanceStatus, error) { + f.called = "Status" + + return &provider.InstanceStatus{State: provider.StateRunning}, nil +} + +func (f *fakeProvider) Deploy(context.Context, provider.DeployRequest) (*provider.DeployResult, error) { + return &provider.DeployResult{}, nil +} +func (f *fakeProvider) Rollback(context.Context, id.ID, id.ID) error { return nil } +func (f *fakeProvider) Scale(context.Context, id.ID, provider.ResourceSpec) error { return nil } + +func (f *fakeProvider) Resources(context.Context, id.ID) (*provider.ResourceUsage, error) { + return &provider.ResourceUsage{}, nil +} + +func (f *fakeProvider) Logs(context.Context, id.ID, provider.LogOptions) (io.ReadCloser, error) { + return nil, nil +} + +func (f *fakeProvider) Exec(context.Context, id.ID, provider.ExecRequest) (*provider.ExecResult, error) { + return &provider.ExecResult{}, nil +} + +func (f *fakeProvider) ApplyManifests(context.Context, provider.ManifestApplyRequest) (*provider.ProvisionResult, error) { + f.called = "ApplyManifests" + + return &provider.ProvisionResult{ProviderRef: "fake"}, nil +} +func (f *fakeProvider) DeleteManifests(context.Context, id.ID) error { + f.called = "DeleteManifests" + + return nil +} + +func (f *fakeProvider) ManifestStatus(context.Context, id.ID) (*provider.InstanceStatus, error) { + f.called = "ManifestStatus" + + return &provider.InstanceStatus{State: provider.StateRunning}, nil +} + +func (f *fakeProvider) HelmInstall(context.Context, provider.HelmInstallRequest) (*provider.ProvisionResult, error) { + f.called = "HelmInstall" + + return &provider.ProvisionResult{ProviderRef: "fake"}, nil +} + +func (f *fakeProvider) HelmUpgrade(context.Context, provider.HelmUpgradeRequest) (*provider.DeployResult, error) { + return &provider.DeployResult{}, nil +} +func (f *fakeProvider) HelmUninstall(context.Context, id.ID) error { + f.called = "HelmUninstall" + + return nil +} + +func (f *fakeProvider) HelmStatus(context.Context, id.ID) (*provider.InstanceStatus, error) { + f.called = "HelmStatus" + + return &provider.InstanceStatus{State: provider.StateRunning}, nil +} + +func (f *fakeProvider) ArgoApply(context.Context, provider.ArgoApplyRequest) (*provider.ProvisionResult, error) { + f.called = "ArgoApply" + + return &provider.ProvisionResult{ProviderRef: "fake"}, nil +} +func (f *fakeProvider) ArgoDelete(context.Context, id.ID) error { + f.called = "ArgoDelete" + + return nil +} + +func (f *fakeProvider) ArgoStatus(context.Context, id.ID) (*provider.InstanceStatus, error) { + f.called = "ArgoStatus" + + return &provider.InstanceStatus{State: provider.StateRunning}, nil +} + +func allCaps() []provider.Capability { + return []provider.Capability{provider.CapProvision, provider.CapManifests, provider.CapHelm, provider.CapArgoCD} +} + +func TestProvision_RoutesByType(t *testing.T) { + tests := []struct { + name string + source provider.RenderedSource + want string + }{ + {"services", provider.RenderedSource{Type: provider.SourceServices, Services: []provider.ServiceSpec{{Name: "w", Image: "nginx"}}}, "Provision"}, + {"manifests", provider.RenderedSource{Type: provider.SourceManifests, Manifests: &provider.RenderedManifests{Docs: []string{"kind: Pod"}}}, "ApplyManifests"}, + {"helm", provider.RenderedSource{Type: provider.SourceHelm, Helm: &provider.RenderedHelm{Chart: "redis"}}, "HelmInstall"}, + {"argocd", provider.RenderedSource{Type: provider.SourceArgoCD, ArgoCD: &provider.ArgoCDSource{RepoURL: "x"}}, "ArgoApply"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &fakeProvider{caps: allCaps()} + + if _, err := Provision(context.Background(), p, Request{InstanceID: id.New(id.PrefixInstance), Source: tt.source}); err != nil { + t.Fatalf("provision: %v", err) + } + + if p.called != tt.want { + t.Errorf("called %q, want %q", p.called, tt.want) + } + }) + } +} + +func TestProvision_MissingCapability(t *testing.T) { + // Implements HelmEngine but does NOT advertise CapHelm. + p := &fakeProvider{caps: []provider.Capability{provider.CapProvision}} + + _, err := Provision(context.Background(), p, Request{ + InstanceID: id.New(id.PrefixInstance), + Source: provider.RenderedSource{Type: provider.SourceHelm, Helm: &provider.RenderedHelm{Chart: "redis"}}, + }) + if !errors.Is(err, ctrlplane.ErrUnsupportedSource) { + t.Fatalf("expected ErrUnsupportedSource, got %v", err) + } +} + +func TestDeprovision_RoutesByType(t *testing.T) { + tests := []struct { + name string + st provider.SourceType + want string + }{ + {"services", provider.SourceServices, "Deprovision"}, + {"empty-legacy", "", "Deprovision"}, + {"manifests", provider.SourceManifests, "DeleteManifests"}, + {"helm", provider.SourceHelm, "HelmUninstall"}, + {"argocd", provider.SourceArgoCD, "ArgoDelete"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &fakeProvider{caps: allCaps()} + + if err := Deprovision(context.Background(), p, tt.st, id.New(id.PrefixInstance)); err != nil { + t.Fatalf("deprovision: %v", err) + } + + if p.called != tt.want { + t.Errorf("called %q, want %q", p.called, tt.want) + } + }) + } +} + +func TestStatus_RoutesByType(t *testing.T) { + tests := []struct { + name string + st provider.SourceType + want string + }{ + {"services", provider.SourceServices, "Status"}, + {"manifests", provider.SourceManifests, "ManifestStatus"}, + {"helm", provider.SourceHelm, "HelmStatus"}, + {"argocd", provider.SourceArgoCD, "ArgoStatus"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &fakeProvider{caps: allCaps()} + + if _, err := Status(context.Background(), p, tt.st, id.New(id.PrefixInstance)); err != nil { + t.Fatalf("status: %v", err) + } + + if p.called != tt.want { + t.Errorf("called %q, want %q", p.called, tt.want) + } + }) + } +} + +func TestDeprovision_MissingCapability(t *testing.T) { + p := &fakeProvider{caps: []provider.Capability{provider.CapProvision}} + + err := Deprovision(context.Background(), p, provider.SourceArgoCD, id.New(id.PrefixInstance)) + if !errors.Is(err, ctrlplane.ErrUnsupportedSource) { + t.Fatalf("expected ErrUnsupportedSource, got %v", err) + } +} diff --git a/errors.go b/errors.go index 3f37eae..c879599 100644 --- a/errors.go +++ b/errors.go @@ -48,4 +48,14 @@ var ( // this backend. Used by store backends that satisfy an interface // for compilation but defer real persistence to a later phase. ErrNotImplemented = errors.New("ctrlplane: not implemented") + + // ErrInvalidSource indicates a deployment source is malformed — its + // Type does not match a single populated payload, or required fields + // for that source type are missing. + ErrInvalidSource = errors.New("ctrlplane: invalid deployment source") + + // ErrUnsupportedSource indicates the selected provider cannot deploy + // the requested source type (e.g. a Helm source on a provider without + // the helm capability). + ErrUnsupportedSource = errors.New("ctrlplane: provider does not support deployment source") ) diff --git a/go.mod b/go.mod index 6f8b562..78b0f0e 100644 --- a/go.mod +++ b/go.mod @@ -18,21 +18,38 @@ require ( github.com/xraph/vessel v1.0.2 go.jetify.com/typeid/v2 v2.0.0-alpha.3 go.mongodb.org/mongo-driver/v2 v2.5.0 + helm.sh/helm/v3 v3.21.0 k8s.io/api v0.35.5 k8s.io/apimachinery v0.35.5 k8s.io/client-go v0.35.5 + sigs.k8s.io/kustomize/api v0.21.1 + sigs.k8s.io/kustomize/kyaml v0.21.1 + sigs.k8s.io/yaml v1.6.0 ) require ( + dario.cat/mergo v1.0.1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/BurntSushi/toml v1.6.0 // indirect - github.com/Microsoft/go-winio v0.4.21 // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/Masterminds/squirrel v1.5.4 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Oudwins/tailwind-merge-go v0.2.1 // indirect github.com/armon/go-metrics v0.4.1 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chai2010/gettext-go v1.0.2 // indirect + github.com/containerd/containerd v1.7.30 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -41,11 +58,15 @@ require ( github.com/dunglas/httpsfv v1.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch v5.9.11+incompatible // indirect + github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/go-errors/errors v1.4.2 // indirect + github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -55,14 +76,19 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.4.0 // indirect github.com/gofrs/uuid/v5 v5.3.2 // indirect + github.com/google/btree v1.1.3 // indirect github.com/google/flatbuffers v25.2.10+incompatible // indirect github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/gosuri/uitable v0.0.4 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/consul/api v1.33.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect @@ -72,87 +98,114 @@ require ( github.com/hashicorp/go-rootcerts v1.0.2 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/hashicorp/serf v0.10.1 // indirect + github.com/huandu/xstrings v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.8.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lib/pq v1.11.2 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/morikuni/aec v1.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/prometheus/client_golang v1.19.1 // indirect - github.com/prometheus/client_model v0.5.0 // indirect - github.com/prometheus/common v0.48.0 // indirect - github.com/prometheus/procfs v0.12.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/webtransport-go v0.10.0 // indirect github.com/redis/go-redis/v9 v9.14.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/rubenv/sql-migrate v1.8.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spf13/cast v1.7.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/xlab/treeprint v1.2.0 // indirect github.com/xraph/confy v0.5.0 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.47.0 // indirect + golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sync v0.19.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.12.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.78.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect + google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.2 // indirect + k8s.io/apiextensions-apiserver v0.35.1 // indirect + k8s.io/apiserver v0.35.1 // indirect + k8s.io/cli-runtime v0.35.1 // indirect + k8s.io/component-base v0.35.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/kubectl v0.35.1 // indirect k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect modernc.org/libc v1.68.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.46.1 // indirect nhooyr.io/websocket v1.8.17 // indirect + oras.land/oras-go/v2 v2.6.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 139c8a7..cfd12b7 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,28 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro= -github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Oudwins/tailwind-merge-go v0.2.1 h1:jxRaEqGtwwwF48UuFIQ8g8XT7YSualNuGzCvQ89nPFE= github.com/Oudwins/tailwind-merge-go v0.2.1/go.mod h1:kkZodgOPvZQ8f7SIrlWkG/w1g9JTbtnptnePIh3V72U= github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= @@ -21,11 +37,17 @@ github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJ github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -35,15 +57,28 @@ github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F9 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= +github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/containerd/containerd v1.7.30 h1:/2vezDpLDVGGmkUXmlNPLCCNKHJ5BbC5tJB5JNzQhqE= +github.com/containerd/containerd v1.7.30/go.mod h1:fek494vwJClULlTpExsmOyKCMUAbuVjlFsJQc4/j44M= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -56,12 +91,22 @@ github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa5 github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= +github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= +github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= @@ -70,6 +115,10 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= @@ -77,12 +126,20 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= +github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= +github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= @@ -108,11 +165,15 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= @@ -143,8 +204,16 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= +github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/consul/api v1.33.0 h1:MnFUzN1Bo6YDGi/EsRLbVNgA4pyCymmcswrE5j4OHBM= github.com/hashicorp/consul/api v1.33.0/go.mod h1:vLz2I/bqqCYiG0qRHGerComvbwSWKswc8rRFtnYBrIw= github.com/hashicorp/consul/sdk v0.17.0 h1:N/JigV6y1yEMfTIhXoW0DXUecM2grQnFuRpY7PcLHLI= @@ -183,6 +252,8 @@ github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= @@ -191,6 +262,10 @@ github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -199,6 +274,8 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= @@ -218,8 +295,19 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= +github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= @@ -236,14 +324,25 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= -github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= @@ -260,6 +359,8 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -278,6 +379,10 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -287,31 +392,39 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= +github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= -github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= -github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= -github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= -github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= -github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= -github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI= github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= +github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= +github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ= github.com/redis/go-redis/v9 v9.14.1 h1:nDCrEiJmfOWhD76xlaw+HXT0c9hfNWeXgl0vIRYSDvQ= github.com/redis/go-redis/v9 v9.14.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -320,16 +433,30 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rubenv/sql-migrate v1.8.1 h1:EPNwCvjAowHI3TnZ+4fQu3a915OpnQoPAjTXCGOy2U0= +github.com/rubenv/sql-migrate v1.8.1/go.mod h1:BTIKBORjzyxZDS6dzoiw6eAFYJ1iNlGAtjn4LGeVjS8= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -339,6 +466,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= @@ -357,6 +485,8 @@ github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/xraph/confy v0.5.0 h1:7dK3hx3MQKlNPK9mFSm07iyU05kUnx6um8d86/gyajg= github.com/xraph/confy v0.5.0/go.mod h1:/uhVfKibPR+kn7MI9LkVVekk84NP0sxsKZ9sFQoQ5Kc= github.com/xraph/forge v1.6.6 h1:yr2Oft+KRAcDbYVioORUGEuGo7Ecc5SiHZbiC0ZEK4s= @@ -384,26 +514,52 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w= +go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE= +go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 h1:GJkybS+crDMdExT/BUNCEgfrmfboztcS6PhvSo88HKM= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0/go.mod h1:NuAyxRYIG2lKX3YQkB+83StTxM7s52PUUkRRiC0wnYI= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU= +go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= +go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= +go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= @@ -412,16 +568,16 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -435,18 +591,18 @@ golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -460,14 +616,15 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -475,16 +632,16 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -495,14 +652,14 @@ golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= @@ -525,16 +682,28 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +helm.sh/helm/v3 v3.21.0 h1:9TRbaXQH+BIKLLDYlu++JsyWodS5kBBOLF7C7HY5+cs= +helm.sh/helm/v3 v3.21.0/go.mod h1:5IvU6Ae6ruB/vasVHhnC1IU5RvqFM349vLYS1BiHqeY= k8s.io/api v0.35.5 h1:BrFeUDGY/LBtlA1R5RoxhlYRHs76RnQBc6xbm/y7hsQ= k8s.io/api v0.35.5/go.mod h1:xWkFhMnoPZdTAQh95Rlw3zZpUUNVlFHcuESUYd06BWM= +k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= +k8s.io/apiextensions-apiserver v0.35.1/go.mod h1:2CN4fe1GZ3HMe4wBr25qXyJnJyZaquy4nNlNmb3R7AQ= k8s.io/apimachinery v0.35.5 h1:lbjjjUfVeVqFbiOpyhqZHc8DhiYkWOxSNij7lHx2U8Y= k8s.io/apimachinery v0.35.5/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc= +k8s.io/apiserver v0.35.1 h1:potxdhhTL4i6AYAa2QCwtlhtB1eCdWQFvJV6fXgJzxs= +k8s.io/apiserver v0.35.1/go.mod h1:BiL6Dd3A2I/0lBnteXfWmCFobHM39vt5+hJQd7Lbpi4= +k8s.io/cli-runtime v0.35.1 h1:uKcXFe8J7AMAM4Gm2JDK4mp198dBEq2nyeYtO+JfGJE= +k8s.io/cli-runtime v0.35.1/go.mod h1:55/hiXIq1C8qIJ3WBrWxEwDLdHQYhBNRdZOz9f7yvTw= k8s.io/client-go v0.35.5 h1:wUrgqVSmFRw75bgSHY7X0G/hZM/QYpV0Hg7SYYOYpFk= k8s.io/client-go v0.35.5/go.mod h1:Z0mDcAJsX1Y7RQfuQlJipiRtqf8Mhk2VDu1/JvRqdGo= +k8s.io/component-base v0.35.1 h1:XgvpRf4srp037QWfGBLFsYMUQJkE5yMa94UsJU7pmcE= +k8s.io/component-base v0.35.1/go.mod h1:HI/6jXlwkiOL5zL9bqA3en1Ygv60F03oEpnuU1G56Bs= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/kubectl v0.35.1 h1:zP3Er8C5i1dcAFUMh9Eva0kVvZHptXIn/+8NtRWMxwg= +k8s.io/kubectl v0.35.1/go.mod h1:cQ2uAPs5IO/kx8R5s5J3Ihv3VCYwrx0obCXum0CvnXo= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= @@ -567,8 +736,14 @@ modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= +sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= +sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= +sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= diff --git a/instance/instance.go b/instance/instance.go index 961ff0d..6471ead 100644 --- a/instance/instance.go +++ b/instance/instance.go @@ -44,6 +44,11 @@ type Instance struct { // to the new Release. Services []provider.ServiceSpec `db:"services" json:"services"` + // Source records what was deployed (services | helm | manifests | + // argocd) so teardown and status route to the right provider engine. + // Empty on legacy instances, which are treated as services. + Source provider.DeploymentSource `db:"source" json:"source,omitzero"` + // Endpoints is the union of every service's accessible endpoints, // each tagged with ServiceName. Endpoints []provider.Endpoint `db:"endpoints" json:"endpoints,omitempty"` diff --git a/instance/service.go b/instance/service.go index 27c2da6..de5b44d 100644 --- a/instance/service.go +++ b/instance/service.go @@ -7,6 +7,7 @@ import ( "github.com/xraph/ctrlplane/id" "github.com/xraph/ctrlplane/provider" + "github.com/xraph/ctrlplane/vars" ) // Service manages instance lifecycle operations. @@ -87,14 +88,28 @@ type DatacenterResolver interface { } // CreateRequest holds the parameters for creating a new instance. +// +// A non-services deployment is described via Source (helm | manifests | +// argocd) plus optional Variables/VariableValues resolved at provision +// time. For backward compatibility, callers may instead populate Services +// alone — Create projects them onto a services Source. type CreateRequest struct { Name string `json:"name" validate:"required"` DatacenterID id.ID `json:"datacenter_id,omitzero"` ProviderName string `json:"provider_name,omitempty"` Region string `json:"region,omitempty"` Kind provider.WorkloadKind `json:"kind,omitempty"` - Services []provider.ServiceSpec `json:"services" validate:"required,min=1"` + Services []provider.ServiceSpec `json:"services,omitempty"` Labels map[string]string `json:"labels,omitempty"` + + // Source describes a non-services deployment. When empty, Services is + // projected onto a services Source. + Source provider.DeploymentSource `json:"source,omitzero"` + + // Variables and VariableValues are resolved against derived instance + // context and injected into the rendered Source. + Variables []vars.Definition `json:"variables,omitempty"` + VariableValues map[string]any `json:"variable_values,omitempty"` } // UpdateRequest holds the parameters for updating an instance. diff --git a/instance/service_impl.go b/instance/service_impl.go index efe77a3..92b837b 100644 --- a/instance/service_impl.go +++ b/instance/service_impl.go @@ -10,9 +10,12 @@ import ( ctrlplane "github.com/xraph/ctrlplane" "github.com/xraph/ctrlplane/auth" + "github.com/xraph/ctrlplane/dispatch" "github.com/xraph/ctrlplane/event" "github.com/xraph/ctrlplane/id" "github.com/xraph/ctrlplane/provider" + "github.com/xraph/ctrlplane/render" + "github.com/xraph/ctrlplane/vars" ) // service implements the Service interface. @@ -67,8 +70,19 @@ func (s *service) Create(ctx context.Context, req CreateRequest) (*Instance, err return nil, fmt.Errorf("create instance: resolve provider: %w", err) } - if len(req.Services) == 0 { - return nil, errors.New("create instance: at least one service required") + // Resolve the effective deployment source: an explicit Source, or + // legacy Services projected onto a services Source. + source := req.Source + if source.Type == "" && len(req.Services) > 0 { + source = provider.DeploymentSource{Type: provider.SourceServices, Services: req.Services} + } + + if source.Type == "" { + return nil, errors.New("create instance: a source or services is required") + } + + if err := source.Validate(); err != nil { + return nil, fmt.Errorf("create instance: %w", err) } kind := req.Kind @@ -87,7 +101,8 @@ func (s *service) Create(ctx context.Context, req CreateRequest) (*Instance, err Region: req.Region, State: provider.StateProvisioning, Kind: kind, - Services: req.Services, + Services: source.Services, + Source: source, Labels: req.Labels, } @@ -95,14 +110,7 @@ func (s *service) Create(ctx context.Context, req CreateRequest) (*Instance, err return nil, fmt.Errorf("create instance: insert: %w", err) } - result, err := p.Provision(ctx, provider.ProvisionRequest{ - InstanceID: inst.ID, - TenantID: claims.TenantID, - Name: req.Name, - Kind: kind, - Services: req.Services, - Labels: req.Labels, - }) + result, err := s.provisionSource(ctx, p, inst, source, req) if err != nil { // Mark the instance as failed if provisioning fails. inst.State = provider.StateFailed @@ -143,6 +151,44 @@ func (s *service) Create(ctx context.Context, req CreateRequest) (*Instance, err return inst, nil } +// provisionSource resolves the instance's variables against its derived +// context, renders the deployment source, and dispatches provisioning to the +// appropriate provider engine. Services flow through the same path — +// rendering is a no-op when no templates are present and dispatch falls +// through to the core Provision. +func (s *service) provisionSource( + ctx context.Context, + p provider.Provider, + inst *Instance, + source provider.DeploymentSource, + req CreateRequest, +) (*provider.ProvisionResult, error) { + derived := vars.Scope{ + Instance: vars.InstanceContext{ID: inst.ID.String(), Name: inst.Name}, + Tenant: vars.TenantContext{ID: inst.TenantID}, + Region: inst.Region, + } + + scope, _, err := vars.NewResolver().Resolve(ctx, req.Variables, req.VariableValues, derived) + if err != nil { + return nil, fmt.Errorf("resolve variables: %w", err) + } + + rendered, err := render.Render(source, scope) + if err != nil { + return nil, fmt.Errorf("render source: %w", err) + } + + return dispatch.Provision(ctx, p, dispatch.Request{ + InstanceID: inst.ID, + TenantID: inst.TenantID, + Name: inst.Name, + Kind: inst.Kind, + Source: rendered, + Labels: inst.Labels, + }) +} + // Get returns an instance by ID, scoped to the caller's tenant. func (s *service) Get(ctx context.Context, instanceID id.ID) (*Instance, error) { claims, err := auth.RequireClaims(ctx) @@ -283,7 +329,7 @@ func (s *service) Delete(ctx context.Context, instanceID id.ID) error { return nil } - if err := p.Deprovision(ctx, inst.ID); err != nil { + if err := dispatch.Deprovision(ctx, p, inst.Source.Type, inst.ID); err != nil { inst.State = provider.StateFailed inst.UpdatedAt = time.Now().UTC() _ = s.store.Update(ctx, inst) diff --git a/instance/source_test.go b/instance/source_test.go new file mode 100644 index 0000000..5dc9450 --- /dev/null +++ b/instance/source_test.go @@ -0,0 +1,119 @@ +package instance + +import ( + "context" + "io" + "testing" + + "github.com/xraph/ctrlplane/auth" + "github.com/xraph/ctrlplane/event" + "github.com/xraph/ctrlplane/id" + "github.com/xraph/ctrlplane/provider" + "github.com/xraph/ctrlplane/vars" +) + +// srcProvider is a fake provider that implements the ManifestEngine, used to +// verify that a manifests-source instance provisions via the engine rather +// than the core Provision path. +type srcProvider struct { + applied bool + deleted bool + manifest bool +} + +func (p *srcProvider) Info() provider.ProviderInfo { return provider.ProviderInfo{Name: "kubernetes"} } + +func (p *srcProvider) Capabilities() []provider.Capability { + return []provider.Capability{provider.CapProvision, provider.CapManifests} +} + +func (p *srcProvider) Provision(context.Context, provider.ProvisionRequest) (*provider.ProvisionResult, error) { + return &provider.ProvisionResult{ProviderRef: "core"}, nil +} +func (p *srcProvider) Deprovision(context.Context, id.ID) error { return nil } +func (p *srcProvider) Start(context.Context, id.ID) error { return nil } +func (p *srcProvider) Stop(context.Context, id.ID) error { return nil } +func (p *srcProvider) Restart(context.Context, id.ID) error { return nil } + +func (p *srcProvider) Status(context.Context, id.ID) (*provider.InstanceStatus, error) { + return &provider.InstanceStatus{State: provider.StateRunning}, nil +} + +func (p *srcProvider) Deploy(context.Context, provider.DeployRequest) (*provider.DeployResult, error) { + return &provider.DeployResult{}, nil +} +func (p *srcProvider) Rollback(context.Context, id.ID, id.ID) error { return nil } +func (p *srcProvider) Scale(context.Context, id.ID, provider.ResourceSpec) error { return nil } + +func (p *srcProvider) Resources(context.Context, id.ID) (*provider.ResourceUsage, error) { + return &provider.ResourceUsage{}, nil +} + +func (p *srcProvider) Logs(context.Context, id.ID, provider.LogOptions) (io.ReadCloser, error) { + return nil, nil +} + +func (p *srcProvider) Exec(context.Context, id.ID, provider.ExecRequest) (*provider.ExecResult, error) { + return &provider.ExecResult{}, nil +} + +func (p *srcProvider) ApplyManifests(context.Context, provider.ManifestApplyRequest) (*provider.ProvisionResult, error) { + p.applied = true + p.manifest = true + + return &provider.ProvisionResult{ProviderRef: "k8s:manifests"}, nil +} + +func (p *srcProvider) DeleteManifests(context.Context, id.ID) error { + p.deleted = true + + return nil +} + +func (p *srcProvider) ManifestStatus(context.Context, id.ID) (*provider.InstanceStatus, error) { + return &provider.InstanceStatus{State: provider.StateRunning}, nil +} + +func srcCtx() context.Context { + return auth.WithClaims(context.Background(), &auth.Claims{TenantID: "ten_1", SubjectID: "u"}) +} + +func TestCreate_ManifestsSource_DispatchesToEngine(t *testing.T) { + store := newDelStore() + prov := &srcProvider{} + registry := provider.NewRegistry() + registry.Register("kubernetes", prov) + + svc := NewService(store, registry, event.NewInMemoryBus(), nil, nil) + + inst, err := svc.Create(srcCtx(), CreateRequest{ + Name: "raw", + ProviderName: "kubernetes", + Source: provider.DeploymentSource{ + Type: provider.SourceManifests, + Manifests: &provider.ManifestSource{Inline: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: {{ .var.n }}\n"}, + }, + Variables: []vars.Definition{{Name: "n", Type: vars.TypeString, Default: "x"}}, + VariableValues: map[string]any{"n": "cm1"}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + if !prov.applied { + t.Error("expected ApplyManifests to be called for a manifests source") + } + + if inst.Source.Type != provider.SourceManifests { + t.Errorf("instance source type = %q, want manifests", inst.Source.Type) + } + + // Delete should route to the manifests engine. + if err := svc.Delete(srcCtx(), inst.ID); err != nil { + t.Fatalf("delete: %v", err) + } + + if !prov.deleted { + t.Error("expected DeleteManifests to be called on delete") + } +} diff --git a/network/network.go b/network/network.go index 1f76ef3..85f3793 100644 --- a/network/network.go +++ b/network/network.go @@ -37,6 +37,13 @@ type Route struct { Protocol string `db:"protocol" json:"protocol"` Weight int `db:"weight" json:"weight"` StripPrefix bool `db:"strip_prefix" json:"strip_prefix"` + // Hostname, when set, scopes the route to a single host (the + // workspace's API hostname). The OctopusRouter uses it as the + // Gateway API HTTPRoute's `hostnames` entry so per-workspace path + // routes don't collide on the shared *.api wildcard listener. + // Transient: carried from AddRouteRequest to the router at create + // time; not persisted (stores map via their own models). + Hostname string `json:"hostname,omitempty"` } // Certificate holds TLS certificate state. diff --git a/network/service.go b/network/service.go index 25e8113..f20d3b5 100644 --- a/network/service.go +++ b/network/service.go @@ -58,6 +58,8 @@ type AddRouteRequest struct { Port int `json:"port" validate:"required"` Protocol string `default:"http" json:"protocol"` Weight int `default:"100" json:"weight"` + StripPrefix bool `json:"strip_prefix,omitempty"` + Hostname string `json:"hostname,omitempty"` } // UpdateRouteRequest holds the parameters for modifying a route. diff --git a/network/service_impl.go b/network/service_impl.go index 066a717..b28f38c 100644 --- a/network/service_impl.go +++ b/network/service_impl.go @@ -187,6 +187,8 @@ func (s *service) AddRoute(ctx context.Context, req AddRouteRequest) (*Route, er Port: req.Port, Protocol: protocol, Weight: weight, + StripPrefix: req.StripPrefix, + Hostname: req.Hostname, } if err := s.store.InsertRoute(ctx, route); err != nil { diff --git a/provider/capability.go b/provider/capability.go index 1dedef2..fe63f5d 100644 --- a/provider/capability.go +++ b/provider/capability.go @@ -45,6 +45,15 @@ const ( // CapTLS indicates the provider supports TLS termination. CapTLS Capability = "tls" + + // CapHelm indicates the provider can deploy a Helm chart source. + CapHelm Capability = "source:helm" + + // CapManifests indicates the provider can apply raw/kustomize manifests. + CapManifests Capability = "source:manifests" + + // CapArgoCD indicates the provider can manage an Argo CD Application source. + CapArgoCD Capability = "source:argocd" ) // HasCapability checks whether the provider supports a given capability. diff --git a/provider/engine.go b/provider/engine.go new file mode 100644 index 0000000..fcfb8fd --- /dev/null +++ b/provider/engine.go @@ -0,0 +1,95 @@ +package provider + +import ( + "context" + + "github.com/xraph/ctrlplane/id" +) + +// ManifestEngine is an optional provider interface for deploying raw or +// kustomize-rendered Kubernetes manifests. Providers that can apply +// arbitrary objects implement it and advertise CapManifests; the workload +// dispatcher type-asserts for it when a source is SourceManifests. Modeled +// as an optional interface (like HealthChecker) so providers that cannot +// apply manifests simply do not implement it. +type ManifestEngine interface { + // ApplyManifests applies every rendered document, labeling each object + // for the instance, and records what it applied so the set can later be + // deleted or inspected. + ApplyManifests(ctx context.Context, req ManifestApplyRequest) (*ProvisionResult, error) + + // DeleteManifests removes every object previously applied for the + // instance. Deleting an already-absent set is not an error. + DeleteManifests(ctx context.Context, instanceID id.ID) error + + // ManifestStatus reports the aggregate state of the applied objects. + ManifestStatus(ctx context.Context, instanceID id.ID) (*InstanceStatus, error) +} + +// ManifestApplyRequest carries the rendered documents and metadata needed +// to apply a manifests source for one instance. +type ManifestApplyRequest struct { + InstanceID id.ID `json:"instance_id"` + TenantID string `json:"tenant_id"` + Namespace string `json:"namespace,omitempty"` + Manifests RenderedManifests `json:"manifests"` + Labels map[string]string `json:"labels,omitempty"` +} + +// HelmEngine is an optional provider interface for deploying Helm charts. +// Providers that drive the Helm SDK implement it and advertise CapHelm; the +// workload dispatcher type-asserts for it when a source is SourceHelm. +type HelmEngine interface { + // HelmInstall installs the chart as a new release for the instance. + HelmInstall(ctx context.Context, req HelmInstallRequest) (*ProvisionResult, error) + + // HelmUpgrade upgrades the instance's existing release. + HelmUpgrade(ctx context.Context, req HelmUpgradeRequest) (*DeployResult, error) + + // HelmUninstall removes the instance's release. + HelmUninstall(ctx context.Context, instanceID id.ID) error + + // HelmStatus reports the release's state. + HelmStatus(ctx context.Context, instanceID id.ID) (*InstanceStatus, error) +} + +// HelmInstallRequest carries the rendered chart reference for a new release. +type HelmInstallRequest struct { + InstanceID id.ID `json:"instance_id"` + TenantID string `json:"tenant_id"` + Namespace string `json:"namespace,omitempty"` + Chart RenderedHelm `json:"chart"` +} + +// HelmUpgradeRequest carries the rendered chart reference for upgrading an +// existing release. +type HelmUpgradeRequest struct { + InstanceID id.ID `json:"instance_id"` + Namespace string `json:"namespace,omitempty"` + Chart RenderedHelm `json:"chart"` +} + +// ArgoEngine is an optional provider interface for managing Argo CD +// Application CRs. Providers that can drive Argo CD implement it and +// advertise CapArgoCD; the workload dispatcher type-asserts for it when a +// source is SourceArgoCD. +type ArgoEngine interface { + // ArgoApply creates or updates the instance's Application resource. + ArgoApply(ctx context.Context, req ArgoApplyRequest) (*ProvisionResult, error) + + // ArgoDelete removes the instance's Application resource. + ArgoDelete(ctx context.Context, instanceID id.ID) error + + // ArgoStatus reports the Application's sync/health as an instance state. + ArgoStatus(ctx context.Context, instanceID id.ID) (*InstanceStatus, error) +} + +// ArgoApplyRequest carries the rendered Argo source for one instance. The +// Application CR is named from the instance id and lives in the provider's +// configured Argo namespace. +type ArgoApplyRequest struct { + InstanceID id.ID `json:"instance_id"` + TenantID string `json:"tenant_id"` + App ArgoCDSource `json:"app"` + Labels map[string]string `json:"labels,omitempty"` +} diff --git a/provider/engine_test.go b/provider/engine_test.go new file mode 100644 index 0000000..30e48a0 --- /dev/null +++ b/provider/engine_test.go @@ -0,0 +1,60 @@ +package provider + +import ( + "context" + "testing" + + "github.com/xraph/ctrlplane/id" +) + +type stubManifestEngine struct{} + +func (stubManifestEngine) ApplyManifests(context.Context, ManifestApplyRequest) (*ProvisionResult, error) { + return nil, nil +} + +func (stubManifestEngine) DeleteManifests(context.Context, id.ID) error { return nil } + +func (stubManifestEngine) ManifestStatus(context.Context, id.ID) (*InstanceStatus, error) { + return nil, nil +} + +func TestManifestEngine_Interface(t *testing.T) { + var _ ManifestEngine = stubManifestEngine{} +} + +type stubHelmEngine struct{} + +func (stubHelmEngine) HelmInstall(context.Context, HelmInstallRequest) (*ProvisionResult, error) { + return nil, nil +} + +func (stubHelmEngine) HelmUpgrade(context.Context, HelmUpgradeRequest) (*DeployResult, error) { + return nil, nil +} + +func (stubHelmEngine) HelmUninstall(context.Context, id.ID) error { return nil } + +func (stubHelmEngine) HelmStatus(context.Context, id.ID) (*InstanceStatus, error) { + return nil, nil +} + +func TestHelmEngine_Interface(t *testing.T) { + var _ HelmEngine = stubHelmEngine{} +} + +type stubArgoEngine struct{} + +func (stubArgoEngine) ArgoApply(context.Context, ArgoApplyRequest) (*ProvisionResult, error) { + return nil, nil +} + +func (stubArgoEngine) ArgoDelete(context.Context, id.ID) error { return nil } + +func (stubArgoEngine) ArgoStatus(context.Context, id.ID) (*InstanceStatus, error) { + return nil, nil +} + +func TestArgoEngine_Interface(t *testing.T) { + var _ ArgoEngine = stubArgoEngine{} +} diff --git a/provider/kubernetes/argo.go b/provider/kubernetes/argo.go new file mode 100644 index 0000000..e292dc8 --- /dev/null +++ b/provider/kubernetes/argo.go @@ -0,0 +1,221 @@ +package kubernetes + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/xraph/ctrlplane/id" + "github.com/xraph/ctrlplane/provider" +) + +// Compile-time check that Provider implements the ArgoEngine optional +// interface. +var _ provider.ArgoEngine = (*Provider)(nil) + +// argoGVR addresses Argo CD Application custom resources. +var argoGVR = schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "applications"} + +const ( + argoAPIVersion = "argoproj.io/v1alpha1" + argoKind = "Application" + argoDefaultProject = "default" + argoInClusterServer = "https://kubernetes.default.svc" +) + +// argoApplication is a minimal typed view of the Argo CD Application CR — +// only the fields ctrlplane sets. Marshaled to unstructured for the dynamic +// client; avoids a dependency on the argo-cd module. +type argoApplication struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata argoMetadata `json:"metadata"` + Spec argoSpec `json:"spec"` +} + +type argoMetadata struct { + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` + Labels map[string]string `json:"labels,omitempty"` +} + +type argoSpec struct { + Project string `json:"project,omitempty"` + Source argoSource `json:"source"` + Destination argoDestination `json:"destination"` + SyncPolicy *argoSyncPolicy `json:"syncPolicy,omitempty"` +} + +type argoSource struct { + RepoURL string `json:"repoURL"` + Path string `json:"path,omitempty"` + TargetRevision string `json:"targetRevision,omitempty"` + Helm *argoHelm `json:"helm,omitempty"` +} + +type argoHelm struct { + ValueFiles []string `json:"valueFiles,omitempty"` + Parameters []argoHelmParam `json:"parameters,omitempty"` +} + +type argoHelmParam struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type argoDestination struct { + Server string `json:"server,omitempty"` + Namespace string `json:"namespace,omitempty"` +} + +type argoSyncPolicy struct { + Automated *argoAutomated `json:"automated,omitempty"` +} + +type argoAutomated struct { + SelfHeal bool `json:"selfHeal,omitempty"` + Prune bool `json:"prune,omitempty"` +} + +// buildArgoApplication assembles the typed Application for a request and +// converts it to an unstructured object, applying ctrlplane defaults +// (project, in-cluster destination server). +func buildArgoApplication(req provider.ArgoApplyRequest, name, namespace string) (*unstructured.Unstructured, error) { + src := req.App + + app := argoApplication{ + APIVersion: argoAPIVersion, + Kind: argoKind, + Metadata: argoMetadata{Name: name, Namespace: namespace, Labels: req.Labels}, + Spec: argoSpec{ + Project: src.Project, + Source: argoSource{ + RepoURL: src.RepoURL, + Path: src.Path, + TargetRevision: src.TargetRevision, + }, + Destination: argoDestination{Server: src.DestServer, Namespace: src.DestNamespace}, + }, + } + + if app.Spec.Project == "" { + app.Spec.Project = argoDefaultProject + } + + if app.Spec.Destination.Server == "" { + app.Spec.Destination.Server = argoInClusterServer + } + + if src.SyncPolicy.Automated { + app.Spec.SyncPolicy = &argoSyncPolicy{ + Automated: &argoAutomated{SelfHeal: src.SyncPolicy.SelfHeal, Prune: src.SyncPolicy.Prune}, + } + } + + if src.Helm != nil { + h := &argoHelm{ValueFiles: src.Helm.ValueFiles} + for k, v := range src.Helm.Parameters { + h.Parameters = append(h.Parameters, argoHelmParam{Name: k, Value: v}) + } + + app.Spec.Source.Helm = h + } + + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&app) + if err != nil { + return nil, fmt.Errorf("kubernetes: build argo application: %w", err) + } + + return &unstructured.Unstructured{Object: obj}, nil +} + +// argoNamespace returns the namespace where Argo Application CRs live. +func (p *Provider) argoNamespace() string { + if p.cfg.ArgoNamespace != "" { + return p.cfg.ArgoNamespace + } + + return "argocd" +} + +// ArgoApply creates or updates the instance's Argo CD Application CR. +func (p *Provider) ArgoApply(ctx context.Context, req provider.ArgoApplyRequest) (*provider.ProvisionResult, error) { + ns := p.argoNamespace() + name := deploymentName(req.InstanceID) + + app, err := buildArgoApplication(req, name, ns) + if err != nil { + return nil, err + } + + if err := applyVia(ctx, p.dynamic.Resource(argoGVR).Namespace(ns), app); err != nil { + return nil, err + } + + return &provider.ProvisionResult{ + ProviderRef: "argocd:" + ns + "/" + name, + }, nil +} + +// ArgoDelete removes the instance's Application CR. A missing Application is +// treated as success. +func (p *Provider) ArgoDelete(ctx context.Context, instanceID id.ID) error { + ns := p.argoNamespace() + name := deploymentName(instanceID) + + err := p.dynamic.Resource(argoGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("kubernetes: delete argo application %s: %w", name, err) + } + + return nil +} + +// ArgoStatus reads the Application's sync and health status and maps them to +// an InstanceState. +func (p *Provider) ArgoStatus(ctx context.Context, instanceID id.ID) (*provider.InstanceStatus, error) { + ns := p.argoNamespace() + name := deploymentName(instanceID) + + app, err := p.dynamic.Resource(argoGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("kubernetes: get argo application %s: %w", name, err) + } + + sync, _, _ := unstructured.NestedString(app.Object, "status", "sync", "status") + health, _, _ := unstructured.NestedString(app.Object, "status", "health", "status") + state := argoStateFor(sync, health) + + return &provider.InstanceStatus{ + State: state, + Ready: state == provider.StateRunning, + Message: fmt.Sprintf("sync=%s health=%s", sync, health), + }, nil +} + +// argoStateFor maps an Argo CD Application's sync and health status to a +// ctrlplane InstanceState. Health is primary; sync refines the healthy case +// (healthy-but-out-of-sync is still converging). +func argoStateFor(sync, health string) provider.InstanceState { + switch health { + case "Healthy": + if sync == "Synced" { + return provider.StateRunning + } + + return provider.StateStarting + case "Progressing": + return provider.StateStarting + case "Degraded": + return provider.StateFailed + case "Suspended": + return provider.StateStopped + default: + return provider.StateProvisioning + } +} diff --git a/provider/kubernetes/argo_test.go b/provider/kubernetes/argo_test.go new file mode 100644 index 0000000..05dfa7e --- /dev/null +++ b/provider/kubernetes/argo_test.go @@ -0,0 +1,207 @@ +package kubernetes + +import ( + "context" + "slices" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + dynamicfake "k8s.io/client-go/dynamic/fake" + + "github.com/xraph/ctrlplane/id" + "github.com/xraph/ctrlplane/provider" +) + +// newArgoTestProvider builds a Provider wired to a fake dynamic client with +// the argo namespace configured. +func newArgoTestProvider() *Provider { + return &Provider{ + cfg: Config{Namespace: "default", ArgoNamespace: "argocd"}, + dynamic: dynamicfake.NewSimpleDynamicClient(runtime.NewScheme()), + } +} + +func argoReq(instanceID id.ID) provider.ArgoApplyRequest { + return provider.ArgoApplyRequest{ + InstanceID: instanceID, + TenantID: "ten_1", + App: provider.ArgoCDSource{ + RepoURL: "https://github.com/acme/repo.git", + Path: "apps/web", + DestNamespace: "prod", + }, + Labels: map[string]string{labelInstanceID: instanceID.String()}, + } +} + +func TestArgoApply(t *testing.T) { + p := newArgoTestProvider() + ctx := context.Background() + instID := id.New(id.PrefixInstance) + + res, err := p.ArgoApply(ctx, argoReq(instID)) + if err != nil { + t.Fatalf("apply: %v", err) + } + + if res.ProviderRef == "" { + t.Error("expected a provider ref") + } + + got, err := p.dynamic.Resource(argoGVR).Namespace("argocd").Get(ctx, deploymentName(instID), metav1.GetOptions{}) + if err != nil { + t.Fatalf("get application: %v", err) + } + + if repo, _, _ := unstructured.NestedString(got.Object, "spec", "source", "repoURL"); repo != "https://github.com/acme/repo.git" { + t.Errorf("repoURL = %q", repo) + } + + if got.GetLabels()[labelInstanceID] != instID.String() { + t.Errorf("missing instance label: %v", got.GetLabels()) + } + + // Re-apply updates in place. + if _, err := p.ArgoApply(ctx, argoReq(instID)); err != nil { + t.Fatalf("re-apply: %v", err) + } +} + +func TestArgoStateFor(t *testing.T) { + tests := []struct { + sync string + health string + want provider.InstanceState + }{ + {"Synced", "Healthy", provider.StateRunning}, + {"OutOfSync", "Healthy", provider.StateStarting}, + {"Synced", "Progressing", provider.StateStarting}, + {"Synced", "Degraded", provider.StateFailed}, + {"Synced", "Suspended", provider.StateStopped}, + {"OutOfSync", "Missing", provider.StateProvisioning}, + {"", "", provider.StateProvisioning}, + } + + for _, tt := range tests { + t.Run(tt.sync+"/"+tt.health, func(t *testing.T) { + if got := argoStateFor(tt.sync, tt.health); got != tt.want { + t.Errorf("argoStateFor(%q, %q) = %s, want %s", tt.sync, tt.health, got, tt.want) + } + }) + } +} + +func TestBuildArgoApplication(t *testing.T) { + instID := id.New(id.PrefixInstance) + req := provider.ArgoApplyRequest{ + InstanceID: instID, + TenantID: "ten_1", + App: provider.ArgoCDSource{ + RepoURL: "https://github.com/acme/repo.git", + Path: "apps/web", + TargetRevision: "main", + DestNamespace: "prod", + SyncPolicy: provider.ArgoSyncPolicy{Automated: true, SelfHeal: true, Prune: true}, + }, + Labels: map[string]string{labelInstanceID: instID.String()}, + } + + u, err := buildArgoApplication(req, "myapp", "argocd") + if err != nil { + t.Fatalf("build: %v", err) + } + + if u.GetAPIVersion() != "argoproj.io/v1alpha1" || u.GetKind() != "Application" { + t.Errorf("gvk = %s/%s", u.GetAPIVersion(), u.GetKind()) + } + + if u.GetName() != "myapp" || u.GetNamespace() != "argocd" { + t.Errorf("name/ns = %s/%s", u.GetName(), u.GetNamespace()) + } + + if u.GetLabels()[labelInstanceID] != instID.String() { + t.Errorf("missing instance label: %v", u.GetLabels()) + } + + if project, _, _ := unstructured.NestedString(u.Object, "spec", "project"); project != "default" { + t.Errorf("project = %q, want default", project) + } + + if repo, _, _ := unstructured.NestedString(u.Object, "spec", "source", "repoURL"); repo != "https://github.com/acme/repo.git" { + t.Errorf("repoURL = %q", repo) + } + + if server, _, _ := unstructured.NestedString(u.Object, "spec", "destination", "server"); server != "https://kubernetes.default.svc" { + t.Errorf("dest server = %q, want in-cluster default", server) + } + + if ns, _, _ := unstructured.NestedString(u.Object, "spec", "destination", "namespace"); ns != "prod" { + t.Errorf("dest namespace = %q", ns) + } + + if selfHeal, _, _ := unstructured.NestedBool(u.Object, "spec", "syncPolicy", "automated", "selfHeal"); !selfHeal { + t.Errorf("selfHeal not set") + } +} + +func TestArgoDelete(t *testing.T) { + p := newArgoTestProvider() + ctx := context.Background() + instID := id.New(id.PrefixInstance) + + if _, err := p.ArgoApply(ctx, argoReq(instID)); err != nil { + t.Fatalf("apply: %v", err) + } + + if err := p.ArgoDelete(ctx, instID); err != nil { + t.Fatalf("delete: %v", err) + } + + if _, err := p.dynamic.Resource(argoGVR).Namespace("argocd").Get(ctx, deploymentName(instID), metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Errorf("application should be gone, got %v", err) + } + + if err := p.ArgoDelete(ctx, instID); err != nil { + t.Fatalf("second delete should be no-op, got %v", err) + } +} + +func TestArgoStatus(t *testing.T) { + p := newArgoTestProvider() + ctx := context.Background() + instID := id.New(id.PrefixInstance) + name := deploymentName(instID) + + app := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "argoproj.io/v1alpha1", + "kind": "Application", + "metadata": map[string]any{"name": name, "namespace": "argocd"}, + "status": map[string]any{ + "sync": map[string]any{"status": "Synced"}, + "health": map[string]any{"status": "Healthy"}, + }, + }} + + if _, err := p.dynamic.Resource(argoGVR).Namespace("argocd").Create(ctx, app, metav1.CreateOptions{}); err != nil { + t.Fatalf("seed application: %v", err) + } + + st, err := p.ArgoStatus(ctx, instID) + if err != nil { + t.Fatalf("status: %v", err) + } + + if st.State != provider.StateRunning || !st.Ready { + t.Errorf("state=%s ready=%v, want running/ready", st.State, st.Ready) + } +} + +func TestCapabilities_IncludesArgoCD(t *testing.T) { + caps := (&Provider{}).Capabilities() + if !slices.Contains(caps, provider.CapArgoCD) { + t.Errorf("capabilities missing CapArgoCD: %v", caps) + } +} diff --git a/provider/kubernetes/config.go b/provider/kubernetes/config.go index a154775..2465dce 100644 --- a/provider/kubernetes/config.go +++ b/provider/kubernetes/config.go @@ -42,4 +42,8 @@ type Config struct { // Corresponds to the Kubernetes podSpec.imagePullSecrets field. // Use CP_K8S_IMAGE_PULL_SECRETS (comma-separated) to configure via env. ImagePullSecrets []string `env:"CP_K8S_IMAGE_PULL_SECRETS" json:"image_pull_secrets,omitempty"` + + // ArgoNamespace is the namespace where ctrlplane creates Argo CD + // Application CRs (where Argo CD itself runs). Defaults to "argocd". + ArgoNamespace string `default:"argocd" env:"CP_K8S_ARGO_NAMESPACE" json:"argo_namespace,omitempty"` } diff --git a/provider/kubernetes/helm.go b/provider/kubernetes/helm.go new file mode 100644 index 0000000..9e20027 --- /dev/null +++ b/provider/kubernetes/helm.go @@ -0,0 +1,191 @@ +package kubernetes + +import ( + "context" + "fmt" + "strconv" + + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/release" + + "github.com/xraph/ctrlplane/id" + "github.com/xraph/ctrlplane/provider" +) + +// releaseName is the Helm release name for an instance. It is derived +// solely from the instance id so uninstall and status — which receive only +// the id — can reconstruct it deterministically. A chart's ReleaseName hint +// is intentionally not used as the release identity. +func releaseName(instanceID id.ID) string { + return deploymentName(instanceID) +} + +// helmNamespace returns the effective namespace for a helm operation. +func (p *Provider) helmNamespace(reqNamespace string) string { + if reqNamespace != "" { + return reqNamespace + } + + return p.cfg.Namespace +} + +// runInstall installs a chart as a new release. +func runInstall(cfg *action.Configuration, ch *chart.Chart, name, namespace string, values map[string]any) (*release.Release, error) { + inst := action.NewInstall(cfg) + inst.ReleaseName = name + inst.Namespace = namespace + + rel, err := inst.Run(ch, values) + if err != nil { + return nil, fmt.Errorf("helm install: %w", err) + } + + return rel, nil +} + +// HelmInstall installs the rendered chart as a new release for the instance. +func (p *Provider) HelmInstall(_ context.Context, req provider.HelmInstallRequest) (*provider.ProvisionResult, error) { + ns := p.helmNamespace(req.Namespace) + + cfg, err := p.helmConfig(ns) + if err != nil { + return nil, fmt.Errorf("kubernetes: helm config: %w", err) + } + + ch, err := p.loadChart(req.Chart) + if err != nil { + return nil, fmt.Errorf("kubernetes: load chart: %w", err) + } + + name := releaseName(req.InstanceID) + + rel, err := runInstall(cfg, ch, name, ns, req.Chart.Values) + if err != nil { + return nil, fmt.Errorf("kubernetes: helm install %s: %w", name, err) + } + + return &provider.ProvisionResult{ + ProviderRef: "helm:" + rel.Namespace + "/" + rel.Name, + Metadata: map[string]string{"revision": strconv.Itoa(rel.Version)}, + }, nil +} + +// runUpgrade upgrades an existing release to a new chart/values revision. +func runUpgrade(cfg *action.Configuration, ch *chart.Chart, name, namespace string, values map[string]any) (*release.Release, error) { + up := action.NewUpgrade(cfg) + up.Namespace = namespace + + rel, err := up.Run(name, ch, values) + if err != nil { + return nil, fmt.Errorf("helm upgrade: %w", err) + } + + return rel, nil +} + +// HelmUpgrade upgrades the instance's existing release. +func (p *Provider) HelmUpgrade(_ context.Context, req provider.HelmUpgradeRequest) (*provider.DeployResult, error) { + ns := p.helmNamespace(req.Namespace) + + cfg, err := p.helmConfig(ns) + if err != nil { + return nil, fmt.Errorf("kubernetes: helm config: %w", err) + } + + ch, err := p.loadChart(req.Chart) + if err != nil { + return nil, fmt.Errorf("kubernetes: load chart: %w", err) + } + + name := releaseName(req.InstanceID) + + rel, err := runUpgrade(cfg, ch, name, ns, req.Chart.Values) + if err != nil { + return nil, fmt.Errorf("kubernetes: helm upgrade %s: %w", name, err) + } + + return &provider.DeployResult{ + ProviderRef: "helm:" + rel.Namespace + "/" + rel.Name, + Status: string(rel.Info.Status), + }, nil +} + +// runUninstall removes a release. +func runUninstall(cfg *action.Configuration, name string) error { + if _, err := action.NewUninstall(cfg).Run(name); err != nil { + return fmt.Errorf("helm uninstall: %w", err) + } + + return nil +} + +// HelmUninstall removes the instance's release. +func (p *Provider) HelmUninstall(_ context.Context, instanceID id.ID) error { + cfg, err := p.helmConfig(p.cfg.Namespace) + if err != nil { + return fmt.Errorf("kubernetes: helm config: %w", err) + } + + name := releaseName(instanceID) + if err := runUninstall(cfg, name); err != nil { + return fmt.Errorf("kubernetes: helm uninstall %s: %w", name, err) + } + + return nil +} + +// runStatus fetches the current release. +func runStatus(cfg *action.Configuration, name string) (*release.Release, error) { + rel, err := action.NewGet(cfg).Run(name) + if err != nil { + return nil, fmt.Errorf("helm get: %w", err) + } + + return rel, nil +} + +// HelmStatus reports the instance release's state. +func (p *Provider) HelmStatus(_ context.Context, instanceID id.ID) (*provider.InstanceStatus, error) { + cfg, err := p.helmConfig(p.cfg.Namespace) + if err != nil { + return nil, fmt.Errorf("kubernetes: helm config: %w", err) + } + + name := releaseName(instanceID) + + rel, err := runStatus(cfg, name) + if err != nil { + return nil, fmt.Errorf("kubernetes: helm status %s: %w", name, err) + } + + state := helmStateFor(rel.Info.Status) + + return &provider.InstanceStatus{ + State: state, + Ready: state == provider.StateRunning, + Message: rel.Info.Description, + }, nil +} + +// Compile-time check that Provider implements the HelmEngine optional +// interface. +var _ provider.HelmEngine = (*Provider)(nil) + +// helmStateFor maps a Helm release status to a ctrlplane InstanceState. +func helmStateFor(status release.Status) provider.InstanceState { + switch status { + case release.StatusDeployed: + return provider.StateRunning + case release.StatusFailed: + return provider.StateFailed + case release.StatusPendingInstall, release.StatusPendingUpgrade, release.StatusPendingRollback: + return provider.StateStarting + case release.StatusUninstalling: + return provider.StateStopping + case release.StatusUninstalled: + return provider.StateStopped + default: + return provider.StateProvisioning + } +} diff --git a/provider/kubernetes/helm_config.go b/provider/kubernetes/helm_config.go new file mode 100644 index 0000000..1bd26ce --- /dev/null +++ b/provider/kubernetes/helm_config.go @@ -0,0 +1,97 @@ +package kubernetes + +import ( + "fmt" + + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/cli" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/client-go/discovery" + memorycache "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/xraph/ctrlplane/provider" +) + +// helmDriverSecret stores Helm releases as Kubernetes Secrets in the +// release namespace — the default Helm 3 storage backend. Releases live in +// the cluster, never in ctrlplane's store. +const helmDriverSecret = "secret" + +// helmRESTClientGetter adapts an existing rest.Config (plus discovery and +// RESTMapper) to the genericclioptions.RESTClientGetter that the Helm SDK +// expects, so the engine reuses the provider's cluster connection rather +// than re-loading a kubeconfig. +type helmRESTClientGetter struct { + restConfig *rest.Config + discovery discovery.CachedDiscoveryInterface + mapper meta.RESTMapper + namespace string +} + +// ToRESTConfig returns the underlying REST config. +func (g *helmRESTClientGetter) ToRESTConfig() (*rest.Config, error) { + return g.restConfig, nil +} + +// ToDiscoveryClient returns a cached discovery client. +func (g *helmRESTClientGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) { + return g.discovery, nil +} + +// ToRESTMapper returns the REST mapper. +func (g *helmRESTClientGetter) ToRESTMapper() (meta.RESTMapper, error) { + return g.mapper, nil +} + +// ToRawKubeConfigLoader returns a minimal client config carrying the target +// namespace, used by Helm for namespace defaulting. +func (g *helmRESTClientGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig { + overrides := &clientcmd.ConfigOverrides{ + Context: clientcmdapi.Context{Namespace: g.namespace}, + } + + return clientcmd.NewDefaultClientConfig(*clientcmdapi.NewConfig(), overrides) +} + +// defaultHelmConfig builds a cluster-backed action.Configuration for a +// namespace using the secret storage driver. This path requires a live +// cluster and is exercised by integration tests, not unit tests. +func (p *Provider) defaultHelmConfig(namespace string) (*action.Configuration, error) { + getter := &helmRESTClientGetter{ + restConfig: p.restConfig, + discovery: memorycache.NewMemCacheClient(p.client.Discovery()), + mapper: p.mapper, + namespace: namespace, + } + + cfg := new(action.Configuration) + if err := cfg.Init(getter, namespace, helmDriverSecret, func(string, ...any) {}); err != nil { + return nil, fmt.Errorf("helm config init: %w", err) + } + + return cfg, nil +} + +// defaultLoadChart locates and loads a chart from an https chart repo (or a +// local path when Repo is empty). OCI registries need additional client +// setup and are a follow-up. Network-dependent; integration-exercised. +func defaultLoadChart(src provider.RenderedHelm) (*chart.Chart, error) { + cpo := action.ChartPathOptions{RepoURL: src.Repo, Version: src.Version} + + path, err := cpo.LocateChart(src.Chart, cli.New()) + if err != nil { + return nil, fmt.Errorf("locate chart %q: %w", src.Chart, err) + } + + ch, err := loader.Load(path) + if err != nil { + return nil, fmt.Errorf("load chart %q: %w", path, err) + } + + return ch, nil +} diff --git a/provider/kubernetes/helm_test.go b/provider/kubernetes/helm_test.go new file mode 100644 index 0000000..0136f18 --- /dev/null +++ b/provider/kubernetes/helm_test.go @@ -0,0 +1,196 @@ +package kubernetes + +import ( + "context" + "io" + "slices" + "strings" + "testing" + + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" + kubefake "helm.sh/helm/v3/pkg/kube/fake" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/storage" + "helm.sh/helm/v3/pkg/storage/driver" + + "github.com/xraph/ctrlplane/id" + "github.com/xraph/ctrlplane/provider" +) + +// memoryHelmConfig returns an action.Configuration backed by the in-memory +// release driver and a printing (no-op) kube client — no cluster needed. +func memoryHelmConfig() *action.Configuration { + return &action.Configuration{ + Releases: storage.Init(driver.NewMemory()), + KubeClient: &kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: io.Discard}, + Capabilities: chartutil.DefaultCapabilities, + Log: func(string, ...any) {}, + } +} + +// testChart builds a minimal installable chart with one templated ConfigMap. +func testChart() *chart.Chart { + return &chart.Chart{ + Metadata: &chart.Metadata{APIVersion: "v2", Name: "test", Version: "0.1.0"}, + Templates: []*chart.File{{ + Name: "templates/cm.yaml", + Data: []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: {{ .Release.Name }}-cm\ndata:\n k: {{ .Values.k }}\n"), + }}, + } +} + +// newHelmTestProvider wires a Provider to a shared memory-driver config and +// the in-memory test chart. The returned config is the same instance the +// provider uses, so tests can read back stored releases. +func newHelmTestProvider() (*Provider, *action.Configuration) { + cfg := memoryHelmConfig() + p := &Provider{ + cfg: Config{Namespace: "default"}, + helmConfig: func(string) (*action.Configuration, error) { return cfg, nil }, + loadChart: func(provider.RenderedHelm) (*chart.Chart, error) { return testChart(), nil }, + } + + return p, cfg +} + +func TestHelmStateFor(t *testing.T) { + tests := []struct { + status release.Status + want provider.InstanceState + }{ + {release.StatusDeployed, provider.StateRunning}, + {release.StatusFailed, provider.StateFailed}, + {release.StatusPendingInstall, provider.StateStarting}, + {release.StatusPendingUpgrade, provider.StateStarting}, + {release.StatusPendingRollback, provider.StateStarting}, + {release.StatusUninstalling, provider.StateStopping}, + {release.StatusUninstalled, provider.StateStopped}, + {release.StatusUnknown, provider.StateProvisioning}, + } + + for _, tt := range tests { + t.Run(string(tt.status), func(t *testing.T) { + if got := helmStateFor(tt.status); got != tt.want { + t.Errorf("helmStateFor(%s) = %s, want %s", tt.status, got, tt.want) + } + }) + } +} + +func TestHelmInstall(t *testing.T) { + p, cfg := newHelmTestProvider() + ctx := context.Background() + instID := id.New(id.PrefixInstance) + + req := provider.HelmInstallRequest{ + InstanceID: instID, + TenantID: "ten_1", + Namespace: "default", + Chart: provider.RenderedHelm{Chart: "test", Values: map[string]any{"k": "hello"}}, + } + + res, err := p.HelmInstall(ctx, req) + if err != nil { + t.Fatalf("install: %v", err) + } + + if !strings.HasPrefix(res.ProviderRef, "helm:") { + t.Errorf("provider ref = %q, want helm: prefix", res.ProviderRef) + } + + rel, err := cfg.Releases.Last(releaseName(instID)) + if err != nil { + t.Fatalf("read release: %v", err) + } + + if rel.Info.Status != release.StatusDeployed { + t.Errorf("status = %s, want deployed", rel.Info.Status) + } + + if !strings.Contains(rel.Manifest, "hello") { + t.Errorf("rendered manifest missing templated value:\n%s", rel.Manifest) + } +} + +func TestHelmUpgrade(t *testing.T) { + p, cfg := newHelmTestProvider() + ctx := context.Background() + instID := id.New(id.PrefixInstance) + chartRef := provider.RenderedHelm{Chart: "test", Values: map[string]any{"k": "v1"}} + + if _, err := p.HelmInstall(ctx, provider.HelmInstallRequest{InstanceID: instID, Namespace: "default", Chart: chartRef}); err != nil { + t.Fatalf("install: %v", err) + } + + dr, err := p.HelmUpgrade(ctx, provider.HelmUpgradeRequest{ + InstanceID: instID, + Namespace: "default", + Chart: provider.RenderedHelm{Chart: "test", Values: map[string]any{"k": "v2"}}, + }) + if err != nil { + t.Fatalf("upgrade: %v", err) + } + + if dr.Status == "" { + t.Error("expected a deploy status") + } + + rel, err := cfg.Releases.Last(releaseName(instID)) + if err != nil { + t.Fatalf("read release: %v", err) + } + + if rel.Version != 2 { + t.Errorf("revision = %d, want 2", rel.Version) + } + + if !strings.Contains(rel.Manifest, "v2") { + t.Errorf("upgraded manifest missing new value:\n%s", rel.Manifest) + } +} + +func TestHelmUninstall(t *testing.T) { + p, cfg := newHelmTestProvider() + ctx := context.Background() + instID := id.New(id.PrefixInstance) + + if _, err := p.HelmInstall(ctx, provider.HelmInstallRequest{InstanceID: instID, Namespace: "default", Chart: provider.RenderedHelm{Chart: "test", Values: map[string]any{"k": "x"}}}); err != nil { + t.Fatalf("install: %v", err) + } + + if err := p.HelmUninstall(ctx, instID); err != nil { + t.Fatalf("uninstall: %v", err) + } + + if _, err := cfg.Releases.Last(releaseName(instID)); err == nil { + t.Error("expected release to be gone after uninstall") + } +} + +func TestHelmStatus(t *testing.T) { + p, _ := newHelmTestProvider() + ctx := context.Background() + instID := id.New(id.PrefixInstance) + + if _, err := p.HelmInstall(ctx, provider.HelmInstallRequest{InstanceID: instID, Namespace: "default", Chart: provider.RenderedHelm{Chart: "test", Values: map[string]any{"k": "x"}}}); err != nil { + t.Fatalf("install: %v", err) + } + + st, err := p.HelmStatus(ctx, instID) + if err != nil { + t.Fatalf("status: %v", err) + } + + if st.State != provider.StateRunning || !st.Ready { + t.Errorf("state=%s ready=%v, want running/ready", st.State, st.Ready) + } +} + +func TestCapabilities_IncludesHelm(t *testing.T) { + caps := (&Provider{}).Capabilities() + if !slices.Contains(caps, provider.CapHelm) { + t.Errorf("capabilities missing CapHelm: %v", caps) + } +} diff --git a/provider/kubernetes/manifests.go b/provider/kubernetes/manifests.go new file mode 100644 index 0000000..0fe6f6d --- /dev/null +++ b/provider/kubernetes/manifests.go @@ -0,0 +1,354 @@ +package kubernetes + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "strconv" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "sigs.k8s.io/yaml" + + "github.com/xraph/ctrlplane/id" + "github.com/xraph/ctrlplane/provider" +) + +// Compile-time check that Provider implements the ManifestEngine optional +// interface. +var _ provider.ManifestEngine = (*Provider)(nil) + +// fieldManager identifies ctrlplane as the writer of applied objects. +const fieldManager = "ctrlplane" + +// manifestTrackingSuffix is appended to the per-instance ConfigMap that +// records which objects a manifests source applied. +const manifestTrackingSuffix = "-manifests" + +// configMapGVR is the GroupVersionResource for core ConfigMaps, used for +// the per-instance manifest-tracking object. +var configMapGVR = schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} + +// objectRef locates one applied object so it can be fetched or deleted +// without re-parsing the source. +type objectRef struct { + Group string `json:"group"` + Version string `json:"version"` + Resource string `json:"resource"` + Namespace string `json:"namespace"` + Name string `json:"name"` +} + +// trackingName is the name of the per-instance manifest-tracking ConfigMap. +func trackingName(instanceID id.ID) string { + return deploymentName(instanceID) + manifestTrackingSuffix +} + +// parseManifests decodes rendered YAML documents into unstructured objects. +// Empty documents are skipped; an object missing apiVersion or kind is an +// error (it cannot be mapped to a resource). +func parseManifests(docs []string) ([]*unstructured.Unstructured, error) { + objs := make([]*unstructured.Unstructured, 0, len(docs)) + + for i, doc := range docs { + var m map[string]any + if err := yaml.Unmarshal([]byte(doc), &m); err != nil { + return nil, fmt.Errorf("parse manifest %d: %w", i, err) + } + + if len(m) == 0 { + continue + } + + obj := &unstructured.Unstructured{Object: m} + if obj.GetKind() == "" || obj.GetAPIVersion() == "" { + return nil, fmt.Errorf("manifest %d: missing apiVersion or kind", i) + } + + objs = append(objs, obj) + } + + return objs, nil +} + +// resourceFor resolves the dynamic resource interface for an object, using +// the RESTMapper to map its GVK to a GVR and choosing the namespaced or +// cluster-scoped interface. A namespaced object with no namespace set +// inherits (and is stamped with) the provider's default namespace. +func (p *Provider) resourceFor(obj *unstructured.Unstructured) (dynamic.ResourceInterface, error) { + gvk := obj.GroupVersionKind() + + mapping, err := p.mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return nil, fmt.Errorf("kubernetes: rest mapping for %s: %w", gvk, err) + } + + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + ns := obj.GetNamespace() + if ns == "" { + ns = p.cfg.Namespace + obj.SetNamespace(ns) + } + + return p.dynamic.Resource(mapping.Resource).Namespace(ns), nil + } + + return p.dynamic.Resource(mapping.Resource), nil +} + +// applyVia creates the object through ri, or updates it in place when it +// already exists (create-or-update). True server-side apply is deferred to a +// later iteration; this form is deterministic against the fake dynamic +// client and is shared by the manifests and argo engines. +func applyVia(ctx context.Context, ri dynamic.ResourceInterface, obj *unstructured.Unstructured) error { + name := obj.GetName() + + existing, err := ri.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + if _, err := ri.Create(ctx, obj, metav1.CreateOptions{FieldManager: fieldManager}); err != nil { + return fmt.Errorf("kubernetes: create %s %s: %w", obj.GetKind(), name, err) + } + + return nil + } + + return fmt.Errorf("kubernetes: get %s %s: %w", obj.GetKind(), name, err) + } + + obj.SetResourceVersion(existing.GetResourceVersion()) + + if _, err := ri.Update(ctx, obj, metav1.UpdateOptions{FieldManager: fieldManager}); err != nil { + return fmt.Errorf("kubernetes: update %s %s: %w", obj.GetKind(), name, err) + } + + return nil +} + +// applyObject resolves the resource interface for an object via the +// RESTMapper, then applies it create-or-update. +func (p *Provider) applyObject(ctx context.Context, obj *unstructured.Unstructured) error { + ri, err := p.resourceFor(obj) + if err != nil { + return err + } + + return applyVia(ctx, ri, obj) +} + +// objectRefFor builds the tracking ref for an object, resolving its GVR and +// effective namespace via the RESTMapper. +func (p *Provider) objectRefFor(obj *unstructured.Unstructured) (objectRef, error) { + gvk := obj.GroupVersionKind() + + mapping, err := p.mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return objectRef{}, fmt.Errorf("kubernetes: rest mapping for %s: %w", gvk, err) + } + + ns := obj.GetNamespace() + if mapping.Scope.Name() == meta.RESTScopeNameNamespace && ns == "" { + ns = p.cfg.Namespace + } + + return objectRef{ + Group: mapping.Resource.Group, + Version: mapping.Resource.Version, + Resource: mapping.Resource.Resource, + Namespace: ns, + Name: obj.GetName(), + }, nil +} + +// setLabels merges labels into an object's existing label set. +func setLabels(obj *unstructured.Unstructured, labels map[string]string) { + existing := obj.GetLabels() + if existing == nil { + existing = make(map[string]string, len(labels)) + } + + maps.Copy(existing, labels) + obj.SetLabels(existing) +} + +// ApplyManifests applies every rendered document for an instance, labels +// each object, and records the applied refs in a per-instance tracking +// ConfigMap so they can later be deleted or inspected. +func (p *Provider) ApplyManifests(ctx context.Context, req provider.ManifestApplyRequest) (*provider.ProvisionResult, error) { + objs, err := parseManifests(req.Manifests.Docs) + if err != nil { + return nil, fmt.Errorf("kubernetes: %w", err) + } + + extra := make(map[string]string, len(p.cfg.Labels)+len(req.Labels)) + maps.Copy(extra, p.cfg.Labels) + maps.Copy(extra, req.Labels) + labels := instanceLabels(req.InstanceID, req.TenantID, extra) + + refs := make([]objectRef, 0, len(objs)) + + for _, obj := range objs { + setLabels(obj, labels) + + if err := p.applyObject(ctx, obj); err != nil { + return nil, err + } + + ref, err := p.objectRefFor(obj) + if err != nil { + return nil, err + } + + refs = append(refs, ref) + } + + if err := p.writeTracking(ctx, req.InstanceID, labels, refs); err != nil { + return nil, err + } + + return &provider.ProvisionResult{ + ProviderRef: providerRef(p.cfg.Namespace, req.InstanceID), + Metadata: map[string]string{"objects": strconv.Itoa(len(refs))}, + }, nil +} + +// writeTracking stores the applied object refs in a per-instance ConfigMap. +func (p *Provider) writeTracking(ctx context.Context, instanceID id.ID, labels map[string]string, refs []objectRef) error { + data, err := json.Marshal(refs) + if err != nil { + return fmt.Errorf("kubernetes: marshal manifest tracking: %w", err) + } + + cm := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": trackingName(instanceID), + "namespace": p.cfg.Namespace, + }, + "data": map[string]any{"refs": string(data)}, + }} + setLabels(cm, labels) + + return p.applyObject(ctx, cm) +} + +// readTracking returns the object refs recorded for an instance, or nil when +// no tracking ConfigMap exists (nothing was applied, or it was deleted). +func (p *Provider) readTracking(ctx context.Context, instanceID id.ID) ([]objectRef, error) { + cm, err := p.dynamic.Resource(configMapGVR).Namespace(p.cfg.Namespace). + Get(ctx, trackingName(instanceID), metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + + return nil, fmt.Errorf("kubernetes: read manifest tracking: %w", err) + } + + refsJSON, _, err := unstructured.NestedString(cm.Object, "data", "refs") + if err != nil { + return nil, fmt.Errorf("kubernetes: manifest tracking data: %w", err) + } + + var refs []objectRef + if refsJSON != "" { + if err := json.Unmarshal([]byte(refsJSON), &refs); err != nil { + return nil, fmt.Errorf("kubernetes: unmarshal manifest tracking: %w", err) + } + } + + return refs, nil +} + +// resourceForRef returns the dynamic resource interface addressing one +// tracked object. +func (p *Provider) resourceForRef(ref objectRef) dynamic.ResourceInterface { + gvr := schema.GroupVersionResource{Group: ref.Group, Version: ref.Version, Resource: ref.Resource} + if ref.Namespace != "" { + return p.dynamic.Resource(gvr).Namespace(ref.Namespace) + } + + return p.dynamic.Resource(gvr) +} + +// deleteRef deletes one tracked object, treating NotFound as success. +func (p *Provider) deleteRef(ctx context.Context, ref objectRef) error { + if err := p.resourceForRef(ref).Delete(ctx, ref.Name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("kubernetes: delete %s %s: %w", ref.Resource, ref.Name, err) + } + + return nil +} + +// DeleteManifests removes every object previously applied for an instance, +// then the tracking ConfigMap itself. A missing tracking object means there +// is nothing to delete. +func (p *Provider) DeleteManifests(ctx context.Context, instanceID id.ID) error { + refs, err := p.readTracking(ctx, instanceID) + if err != nil { + return err + } + + if refs == nil { + return nil + } + + for _, ref := range refs { + if err := p.deleteRef(ctx, ref); err != nil { + return err + } + } + + err = p.dynamic.Resource(configMapGVR).Namespace(p.cfg.Namespace). + Delete(ctx, trackingName(instanceID), metav1.DeleteOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("kubernetes: delete manifest tracking: %w", err) + } + + return nil +} + +// ManifestStatus reports the aggregate state of an instance's applied +// objects. It is existence-based: all tracked objects present yields +// Running; any missing yields Provisioning. Deep readiness of arbitrary +// resources is out of scope for this iteration. +func (p *Provider) ManifestStatus(ctx context.Context, instanceID id.ID) (*provider.InstanceStatus, error) { + refs, err := p.readTracking(ctx, instanceID) + if err != nil { + return nil, err + } + + if len(refs) == 0 { + return &provider.InstanceStatus{ + State: provider.StateProvisioning, + Ready: false, + Message: "no manifests applied", + }, nil + } + + for _, ref := range refs { + if _, err := p.resourceForRef(ref).Get(ctx, ref.Name, metav1.GetOptions{}); err != nil { + if apierrors.IsNotFound(err) { + return &provider.InstanceStatus{ + State: provider.StateProvisioning, + Ready: false, + Message: fmt.Sprintf("%s %s not found", ref.Resource, ref.Name), + }, nil + } + + return nil, fmt.Errorf("kubernetes: manifest status %s %s: %w", ref.Resource, ref.Name, err) + } + } + + return &provider.InstanceStatus{ + State: provider.StateRunning, + Ready: true, + Message: "all manifests applied", + }, nil +} diff --git a/provider/kubernetes/manifests_test.go b/provider/kubernetes/manifests_test.go new file mode 100644 index 0000000..de96371 --- /dev/null +++ b/provider/kubernetes/manifests_test.go @@ -0,0 +1,238 @@ +package kubernetes + +import ( + "context" + "encoding/json" + "slices" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + + "github.com/xraph/ctrlplane/id" + "github.com/xraph/ctrlplane/provider" +) + +var depGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} + +// manifestReq builds a two-document apply request for the given instance. +func manifestReq(instanceID id.ID) provider.ManifestApplyRequest { + return provider.ManifestApplyRequest{ + InstanceID: instanceID, + TenantID: "ten_1", + Manifests: provider.RenderedManifests{Docs: []string{ + "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm1\ndata:\n k: v\n", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: dep1\n", + }}, + } +} + +// testMapper returns a RESTMapper that knows the resource kinds used in +// these tests, standing in for discovery-backed mapping in production. +func testMapper() meta.RESTMapper { + m := meta.NewDefaultRESTMapper([]schema.GroupVersion{{Version: "v1"}, {Group: "apps", Version: "v1"}}) + m.Add(schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, meta.RESTScopeNamespace) + m.Add(schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, meta.RESTScopeNamespace) + + return m +} + +// cmObject builds an unstructured ConfigMap in the default namespace. +func cmObject(name string, data map[string]any) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{"name": name, "namespace": "default"}, + "data": data, + }} +} + +// newManifestTestProvider builds a Provider wired to a fake dynamic client +// and the test RESTMapper. +func newManifestTestProvider() *Provider { + return &Provider{ + cfg: Config{Namespace: "default"}, + dynamic: dynamicfake.NewSimpleDynamicClient(runtime.NewScheme()), + mapper: testMapper(), + } +} + +func TestApplyObject_CreateThenUpdate(t *testing.T) { + p := newManifestTestProvider() + ctx := context.Background() + + if err := p.applyObject(ctx, cmObject("cm1", map[string]any{"k": "v1"})); err != nil { + t.Fatalf("create: %v", err) + } + + got, err := p.dynamic.Resource(configMapGVR).Namespace("default").Get(ctx, "cm1", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get after create: %v", err) + } + + if data, _, _ := unstructured.NestedString(got.Object, "data", "k"); data != "v1" { + t.Errorf("data.k = %q, want v1", data) + } + + if err := p.applyObject(ctx, cmObject("cm1", map[string]any{"k": "v2"})); err != nil { + t.Fatalf("update: %v", err) + } + + got2, err := p.dynamic.Resource(configMapGVR).Namespace("default").Get(ctx, "cm1", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get after update: %v", err) + } + + if data, _, _ := unstructured.NestedString(got2.Object, "data", "k"); data != "v2" { + t.Errorf("data.k = %q, want v2", data) + } +} + +func TestParseManifests(t *testing.T) { + docs := []string{ + "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm1\ndata:\n k: v\n", + "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: dep1\n", + } + + objs, err := parseManifests(docs) + if err != nil { + t.Fatalf("parse: %v", err) + } + + if len(objs) != 2 { + t.Fatalf("objs = %d, want 2", len(objs)) + } + + if objs[0].GetKind() != "ConfigMap" || objs[0].GetName() != "cm1" { + t.Errorf("obj0 = %s/%s", objs[0].GetKind(), objs[0].GetName()) + } + + if objs[1].GetAPIVersion() != "apps/v1" || objs[1].GetName() != "dep1" { + t.Errorf("obj1 = %s %s", objs[1].GetAPIVersion(), objs[1].GetName()) + } +} + +func TestParseManifests_MissingKind(t *testing.T) { + if _, err := parseManifests([]string{"apiVersion: v1\nmetadata:\n name: x\n"}); err == nil { + t.Fatal("expected error for manifest without kind") + } +} + +func TestApplyManifests(t *testing.T) { + p := newManifestTestProvider() + ctx := context.Background() + instID := id.New(id.PrefixInstance) + + res, err := p.ApplyManifests(ctx, manifestReq(instID)) + if err != nil { + t.Fatalf("apply: %v", err) + } + + if res.ProviderRef == "" { + t.Error("expected a provider ref") + } + + cm, err := p.dynamic.Resource(configMapGVR).Namespace("default").Get(ctx, "cm1", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get cm1: %v", err) + } + + if cm.GetLabels()[labelInstanceID] != instID.String() { + t.Errorf("cm1 missing instance label: %v", cm.GetLabels()) + } + + if _, err := p.dynamic.Resource(depGVR).Namespace("default").Get(ctx, "dep1", metav1.GetOptions{}); err != nil { + t.Fatalf("get dep1: %v", err) + } + + track, err := p.dynamic.Resource(configMapGVR).Namespace("default").Get(ctx, trackingName(instID), metav1.GetOptions{}) + if err != nil { + t.Fatalf("get tracking configmap: %v", err) + } + + refsJSON, _, _ := unstructured.NestedString(track.Object, "data", "refs") + + var refs []objectRef + if err := json.Unmarshal([]byte(refsJSON), &refs); err != nil { + t.Fatalf("unmarshal refs: %v", err) + } + + if len(refs) != 2 { + t.Errorf("tracked refs = %d, want 2", len(refs)) + } +} + +func TestDeleteManifests(t *testing.T) { + p := newManifestTestProvider() + ctx := context.Background() + instID := id.New(id.PrefixInstance) + + if _, err := p.ApplyManifests(ctx, manifestReq(instID)); err != nil { + t.Fatalf("apply: %v", err) + } + + if err := p.DeleteManifests(ctx, instID); err != nil { + t.Fatalf("delete: %v", err) + } + + if _, err := p.dynamic.Resource(configMapGVR).Namespace("default").Get(ctx, "cm1", metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Errorf("cm1 should be gone, got err=%v", err) + } + + if _, err := p.dynamic.Resource(depGVR).Namespace("default").Get(ctx, "dep1", metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Errorf("dep1 should be gone, got err=%v", err) + } + + if _, err := p.dynamic.Resource(configMapGVR).Namespace("default").Get(ctx, trackingName(instID), metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Errorf("tracking configmap should be gone, got err=%v", err) + } + + // Deleting again with no tracking is a no-op. + if err := p.DeleteManifests(ctx, instID); err != nil { + t.Fatalf("second delete should be no-op, got %v", err) + } +} + +func TestManifestStatus(t *testing.T) { + p := newManifestTestProvider() + ctx := context.Background() + instID := id.New(id.PrefixInstance) + + if _, err := p.ApplyManifests(ctx, manifestReq(instID)); err != nil { + t.Fatalf("apply: %v", err) + } + + st, err := p.ManifestStatus(ctx, instID) + if err != nil { + t.Fatalf("status: %v", err) + } + + if st.State != provider.StateRunning || !st.Ready { + t.Errorf("after apply: state=%s ready=%v, want running/ready", st.State, st.Ready) + } + + if err := p.dynamic.Resource(depGVR).Namespace("default").Delete(ctx, "dep1", metav1.DeleteOptions{}); err != nil { + t.Fatalf("delete dep1: %v", err) + } + + st2, err := p.ManifestStatus(ctx, instID) + if err != nil { + t.Fatalf("status after delete: %v", err) + } + + if st2.State == provider.StateRunning { + t.Errorf("after deleting one object, state should not be running, got %s", st2.State) + } +} + +func TestCapabilities_IncludesManifests(t *testing.T) { + caps := (&Provider{}).Capabilities() + if !slices.Contains(caps, provider.CapManifests) { + t.Errorf("capabilities missing CapManifests: %v", caps) + } +} diff --git a/provider/kubernetes/provider.go b/provider/kubernetes/provider.go index afc8987..7b2dd14 100644 --- a/provider/kubernetes/provider.go +++ b/provider/kubernetes/provider.go @@ -5,11 +5,17 @@ import ( "fmt" "io" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" "k8s.io/client-go/tools/clientcmd" ctrlplane "github.com/xraph/ctrlplane" @@ -24,9 +30,18 @@ var ( ) // Provider is a Kubernetes-based infrastructure provider. +// +// helmConfig and loadChart are injectable seams: production wiring builds a +// cluster-backed action.Configuration and a repo/OCI chart loader, while +// tests inject an in-memory storage driver and an in-memory chart. type Provider struct { - cfg Config - client kubernetes.Interface + cfg Config + client kubernetes.Interface + dynamic dynamic.Interface + mapper meta.RESTMapper + restConfig *rest.Config + helmConfig func(namespace string) (*action.Configuration, error) + loadChart func(src provider.RenderedHelm) (*chart.Chart, error) } // New creates a new Kubernetes provider with the given options. @@ -65,6 +80,25 @@ func New(opts ...Option) (*Provider, error) { p.client = client + dyn, err := dynamic.NewForConfig(restCfg) + if err != nil { + return nil, fmt.Errorf("kubernetes: create dynamic client: %w", err) + } + + p.dynamic = dyn + p.restConfig = restCfg + + // Lazily resolve GVK→GVR via discovery; the mapper performs no network + // call until the first manifest apply needs a mapping. + p.mapper = restmapper.NewDeferredDiscoveryRESTMapper( + memory.NewMemCacheClient(client.Discovery()), + ) + + // Default helm seams talk to the real cluster and chart repos. Tests + // override these fields with in-memory equivalents. + p.helmConfig = p.defaultHelmConfig + p.loadChart = defaultLoadChart + return p, nil } @@ -93,6 +127,9 @@ func (p *Provider) Capabilities() []provider.Capability { provider.CapExec, provider.CapRolling, provider.CapVolumes, + provider.CapManifests, + provider.CapHelm, + provider.CapArgoCD, } } diff --git a/provider/rendered.go b/provider/rendered.go new file mode 100644 index 0000000..1b976e3 --- /dev/null +++ b/provider/rendered.go @@ -0,0 +1,31 @@ +package provider + +// RenderedSource is the fully-resolved deployment source a provider applies. +// It is produced by the render package from a DeploymentSource plus a +// resolved variable scope; the provider never sees raw templates or the +// variable layer. Secret values are never embedded here — they travel +// separately as SecretBindings and are materialized at apply time. +type RenderedSource struct { + Type SourceType `json:"type"` + Services []ServiceSpec `json:"services,omitempty"` + Helm *RenderedHelm `json:"helm,omitempty"` + Manifests *RenderedManifests `json:"manifests,omitempty"` + ArgoCD *ArgoCDSource `json:"argocd,omitempty"` // templated in place +} + +// RenderedHelm is a Helm chart reference with its values fully resolved. +type RenderedHelm struct { + Repo string `json:"repo,omitempty"` + Chart string `json:"chart"` + Version string `json:"version,omitempty"` + ReleaseName string `json:"release_name,omitempty"` + Namespace string `json:"namespace,omitempty"` + Values map[string]any `json:"values,omitempty"` +} + +// RenderedManifests holds concrete Kubernetes YAML documents — the output +// of templating inline YAML or running a kustomize build. Each Docs entry +// is exactly one YAML document. +type RenderedManifests struct { + Docs []string `json:"docs"` +} diff --git a/provider/secret_binding.go b/provider/secret_binding.go new file mode 100644 index 0000000..0af7851 --- /dev/null +++ b/provider/secret_binding.go @@ -0,0 +1,19 @@ +package provider + +// SecretBinding is a resolved secret-typed variable. The provider +// materializes it as a native Secret and wires it via valueFrom/envFrom; +// the plaintext value is never carried in this struct, the render output, +// or any persisted snapshot. Lives in the provider package alongside +// SecretRef so vars and render can reference it without a provider → vars +// import edge. +type SecretBinding struct { + // VarName is the logical variable name the binding was resolved from. + VarName string `json:"var_name"` + + // EnvKey is the environment-variable name the workload sees. Defaults + // to UPPER_SNAKE(VarName) when the variable does not specify one. + EnvKey string `json:"env_key"` + + // Ref locates the secret value in the vault, resolved at apply time. + Ref SecretRef `json:"ref"` +} diff --git a/provider/secret_binding_test.go b/provider/secret_binding_test.go new file mode 100644 index 0000000..89316f5 --- /dev/null +++ b/provider/secret_binding_test.go @@ -0,0 +1,30 @@ +package provider + +import ( + "encoding/json" + "testing" + + "github.com/xraph/ctrlplane/secrets" +) + +func TestSecretBinding_JSONRoundTrip(t *testing.T) { + in := SecretBinding{ + VarName: "db-password", + EnvKey: "DB_PASSWORD", + Ref: SecretRef{Key: "tenant/db/password", Type: secrets.SecretEnvVar}, + } + + data, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var out SecretBinding + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if out.VarName != in.VarName || out.EnvKey != in.EnvKey || out.Ref.Key != in.Ref.Key || out.Ref.Type != in.Ref.Type { + t.Errorf("round-trip mismatch: got %+v, want %+v", out, in) + } +} diff --git a/provider/source.go b/provider/source.go new file mode 100644 index 0000000..eb4ce31 --- /dev/null +++ b/provider/source.go @@ -0,0 +1,119 @@ +package provider + +import ( + "fmt" + "strings" + + ctrlplane "github.com/xraph/ctrlplane" +) + +// SourceType identifies which kind of deployment a DeploymentSource +// describes. Exactly one of the DeploymentSource payload fields is set, +// matching the Type. +type SourceType string + +const ( + // SourceServices deploys container services (the ServiceSpec model). + SourceServices SourceType = "services" + + // SourceHelm deploys a Helm chart. + SourceHelm SourceType = "helm" + + // SourceManifests deploys raw multi-doc YAML and/or a kustomize build. + SourceManifests SourceType = "manifests" + + // SourceArgoCD delegates deployment to Argo CD via an Application CR. + SourceArgoCD SourceType = "argocd" +) + +// DeploymentSource is the typed union describing what a workload deploys. +// Exactly one payload field is populated, matching Type. +type DeploymentSource struct { + Type SourceType `json:"type" validate:"required"` + Services []ServiceSpec `json:"services,omitempty"` + Helm *HelmSource `json:"helm,omitempty"` + Manifests *ManifestSource `json:"manifests,omitempty"` + ArgoCD *ArgoCDSource `json:"argocd,omitempty"` +} + +// HelmSource describes a Helm chart to install. Values are the base values +// templated with variables before install; secret values are supplied at +// apply time and never persisted in a snapshot. +type HelmSource struct { + Repo string `json:"repo,omitempty"` // oci:// or https chart repo + Chart string `json:"chart" validate:"required"` + Version string `json:"version,omitempty"` + ReleaseName string `json:"release_name,omitempty"` // defaulted from the instance + Namespace string `json:"namespace,omitempty"` + Values map[string]any `json:"values,omitempty"` + ValuesFiles []string `json:"values_files,omitempty"` +} + +// ManifestSource describes raw Kubernetes manifests, either as inline +// multi-doc YAML or as a kustomize build. At least one is set. +type ManifestSource struct { + Inline string `json:"inline,omitempty"` + Kustomize *KustomizeSource `json:"kustomize,omitempty"` +} + +// KustomizeSource describes an in-memory kustomization. Files maps a +// relative path to its content (one entry is the kustomization.yaml). +// Root is the build path passed to kustomize, defaulting to the fs root. +type KustomizeSource struct { + Files map[string]string `json:"files"` + Root string `json:"root,omitempty"` +} + +// ArgoCDSource describes an Argo CD Application that ctrlplane manages. +type ArgoCDSource struct { + Project string `json:"project,omitempty"` + RepoURL string `json:"repo_url" validate:"required"` + Path string `json:"path,omitempty"` + TargetRevision string `json:"target_revision,omitempty"` + DestServer string `json:"dest_server,omitempty"` + DestNamespace string `json:"dest_namespace,omitempty"` + Helm *ArgoHelm `json:"helm,omitempty"` + SyncPolicy ArgoSyncPolicy `json:"sync_policy,omitzero"` +} + +// ArgoHelm carries Helm-specific parameters for a Helm-typed Argo +// Application source. +type ArgoHelm struct { + ValueFiles []string `json:"value_files,omitempty"` + Parameters map[string]string `json:"parameters,omitempty"` +} + +// ArgoSyncPolicy configures how Argo CD reconciles the Application. +type ArgoSyncPolicy struct { + Automated bool `json:"automated,omitempty"` + SelfHeal bool `json:"self_heal,omitempty"` + Prune bool `json:"prune,omitempty"` +} + +// Validate checks that Type matches a single populated payload with its +// required fields present. It does not deeply validate the payload (e.g. +// ServiceSpec invariants) — that is the owning service's responsibility. +func (s DeploymentSource) Validate() error { + switch s.Type { + case SourceServices: + if len(s.Services) == 0 { + return fmt.Errorf("%w: services source requires services", ctrlplane.ErrInvalidSource) + } + case SourceHelm: + if s.Helm == nil || strings.TrimSpace(s.Helm.Chart) == "" { + return fmt.Errorf("%w: helm source requires a chart", ctrlplane.ErrInvalidSource) + } + case SourceManifests: + if s.Manifests == nil || (strings.TrimSpace(s.Manifests.Inline) == "" && s.Manifests.Kustomize == nil) { + return fmt.Errorf("%w: manifests source requires inline or kustomize", ctrlplane.ErrInvalidSource) + } + case SourceArgoCD: + if s.ArgoCD == nil || strings.TrimSpace(s.ArgoCD.RepoURL) == "" { + return fmt.Errorf("%w: argocd source requires repo_url", ctrlplane.ErrInvalidSource) + } + default: + return fmt.Errorf("%w: unknown source type %q", ctrlplane.ErrInvalidSource, s.Type) + } + + return nil +} diff --git a/provider/source_test.go b/provider/source_test.go new file mode 100644 index 0000000..ee2018a --- /dev/null +++ b/provider/source_test.go @@ -0,0 +1,143 @@ +package provider + +import ( + "encoding/json" + "errors" + "testing" + + ctrlplane "github.com/xraph/ctrlplane" +) + +func TestDeploymentSource_Validate(t *testing.T) { + tests := []struct { + name string + src DeploymentSource + wantErr bool + }{ + { + name: "valid services", + src: DeploymentSource{Type: SourceServices, Services: []ServiceSpec{{Name: "web", Image: "nginx"}}}, + }, + { + name: "valid helm", + src: DeploymentSource{Type: SourceHelm, Helm: &HelmSource{Chart: "redis"}}, + }, + { + name: "valid manifests inline", + src: DeploymentSource{Type: SourceManifests, Manifests: &ManifestSource{Inline: "kind: Pod"}}, + }, + { + name: "valid manifests kustomize", + src: DeploymentSource{Type: SourceManifests, Manifests: &ManifestSource{Kustomize: &KustomizeSource{Files: map[string]string{"kustomization.yaml": ""}}}}, + }, + { + name: "valid argocd", + src: DeploymentSource{Type: SourceArgoCD, ArgoCD: &ArgoCDSource{RepoURL: "https://example.com/repo.git"}}, + }, + { + name: "services without services", + src: DeploymentSource{Type: SourceServices}, + wantErr: true, + }, + { + name: "helm without chart", + src: DeploymentSource{Type: SourceHelm, Helm: &HelmSource{}}, + wantErr: true, + }, + { + name: "manifests with neither", + src: DeploymentSource{Type: SourceManifests, Manifests: &ManifestSource{}}, + wantErr: true, + }, + { + name: "argocd without repo", + src: DeploymentSource{Type: SourceArgoCD, ArgoCD: &ArgoCDSource{}}, + wantErr: true, + }, + { + name: "unknown type", + src: DeploymentSource{Type: SourceType("weird")}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.src.Validate() + if tt.wantErr { + if !errors.Is(err, ctrlplane.ErrInvalidSource) { + t.Errorf("expected ErrInvalidSource, got %v", err) + } + + return + } + + if err != nil { + t.Errorf("expected valid, got %v", err) + } + }) + } +} + +func TestHelmSource_JSONRoundTrip(t *testing.T) { + in := DeploymentSource{ + Type: SourceHelm, + Helm: &HelmSource{ + Repo: "oci://registry.example.com/charts", + Chart: "api", + Version: "1.2.3", + ReleaseName: "api-prod", + Namespace: "prod", + Values: map[string]any{"replicaCount": float64(3)}, + }, + } + + data, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var out DeploymentSource + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if out.Type != SourceHelm || out.Helm == nil || out.Helm.Chart != "api" || out.Helm.Values["replicaCount"] != float64(3) { + t.Errorf("round-trip mismatch: %+v", out.Helm) + } +} + +func TestRenderedSource_JSONRoundTrip(t *testing.T) { + in := RenderedSource{ + Type: SourceManifests, + Manifests: &RenderedManifests{Docs: []string{"kind: Pod", "kind: Service"}}, + } + + data, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var out RenderedSource + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if out.Type != SourceManifests || out.Manifests == nil || len(out.Manifests.Docs) != 2 { + t.Errorf("round-trip mismatch: %+v", out.Manifests) + } +} + +func TestSourceCapabilities_Defined(t *testing.T) { + want := map[Capability]string{ + CapHelm: "source:helm", + CapManifests: "source:manifests", + CapArgoCD: "source:argocd", + } + + for capability, str := range want { + if string(capability) != str { + t.Errorf("capability = %q, want %q", capability, str) + } + } +} diff --git a/render/argo.go b/render/argo.go new file mode 100644 index 0000000..968174a --- /dev/null +++ b/render/argo.go @@ -0,0 +1,33 @@ +package render + +import ( + "fmt" + + "github.com/xraph/ctrlplane/provider" + "github.com/xraph/ctrlplane/vars" +) + +// renderArgo templates the string fields of an ArgoCDSource, returning a new +// source. Helm parameters and the sync policy are carried through unchanged +// — Argo CD resolves chart parameters itself. +func renderArgo(src *provider.ArgoCDSource, scope vars.Scope) (*provider.ArgoCDSource, error) { + out := *src + + for _, f := range []*string{ + &out.Project, + &out.RepoURL, + &out.Path, + &out.TargetRevision, + &out.DestServer, + &out.DestNamespace, + } { + rendered, err := tmplString(*f, scope) + if err != nil { + return nil, fmt.Errorf("argocd field: %w", err) + } + + *f = rendered + } + + return &out, nil +} diff --git a/render/argo_test.go b/render/argo_test.go new file mode 100644 index 0000000..f2206ef --- /dev/null +++ b/render/argo_test.go @@ -0,0 +1,59 @@ +package render + +import ( + "testing" + + "github.com/xraph/ctrlplane/provider" +) + +func TestRender_ArgoCD(t *testing.T) { + src := provider.DeploymentSource{ + Type: provider.SourceArgoCD, + ArgoCD: &provider.ArgoCDSource{ + Project: "default", + RepoURL: "https://github.com/{{ .var.org }}/repo.git", + Path: "apps/{{ .var.app }}", + TargetRevision: "main", + DestNamespace: "{{ .tenant.id }}", + }, + } + + out, err := Render(src, scopeWith(map[string]any{"org": "acme", "app": "web"})) + if err != nil { + t.Fatalf("render: %v", err) + } + + if out.Type != provider.SourceArgoCD || out.ArgoCD == nil { + t.Fatalf("unexpected output: %+v", out) + } + + a := out.ArgoCD + if a.RepoURL != "https://github.com/acme/repo.git" { + t.Errorf("repo = %q", a.RepoURL) + } + + if a.Path != "apps/web" { + t.Errorf("path = %q", a.Path) + } + + if a.DestNamespace != "tnt_1" { + t.Errorf("dest namespace = %q", a.DestNamespace) + } + + if a.Project != "default" || a.TargetRevision != "main" { + t.Errorf("static fields altered: %+v", a) + } +} + +func TestRender_SecretNotInlined(t *testing.T) { + // dbpass is a secret-typed variable, so the resolver excluded it from + // the scope. Referencing it inline must fail rather than render empty. + src := provider.DeploymentSource{ + Type: provider.SourceManifests, + Manifests: &provider.ManifestSource{Inline: "password: {{ .var.dbpass }}"}, + } + + if _, err := Render(src, scopeWith(map[string]any{})); err == nil { + t.Fatal("expected error referencing an out-of-scope secret variable, got nil") + } +} diff --git a/render/doc.go b/render/doc.go new file mode 100644 index 0000000..ed9c8c7 --- /dev/null +++ b/render/doc.go @@ -0,0 +1,9 @@ +// Package render resolves a provider.DeploymentSource against a resolved +// variable scope into a concrete provider.RenderedSource the provider can +// apply. It templates services, helm values, manifest YAML, and argocd +// fields with Go text/template, and builds kustomize sources in memory. +// +// Secret-typed variables are excluded from the scope by the vars resolver, +// so any inline reference to one ({{ .var. }}) fails with a missing +// key — the render-time enforcement of "secrets are never inlined". +package render diff --git a/render/helm.go b/render/helm.go new file mode 100644 index 0000000..15059de --- /dev/null +++ b/render/helm.go @@ -0,0 +1,69 @@ +package render + +import ( + "fmt" + + "github.com/xraph/ctrlplane/provider" + "github.com/xraph/ctrlplane/vars" +) + +// renderHelm carries the chart coordinates through and deep-templates the +// values tree (string leaves only — numbers, bools, and nulls pass through +// unchanged). +func renderHelm(src *provider.HelmSource, scope vars.Scope) (*provider.RenderedHelm, error) { + values, err := templateValue(src.Values, scope) + if err != nil { + return nil, fmt.Errorf("helm values: %w", err) + } + + out := &provider.RenderedHelm{ + Repo: src.Repo, + Chart: src.Chart, + Version: src.Version, + ReleaseName: src.ReleaseName, + Namespace: src.Namespace, + } + + if m, ok := values.(map[string]any); ok { + out.Values = m + } + + return out, nil +} + +// templateValue walks an arbitrary values tree, templating string leaves. +// Maps and slices are rebuilt; scalar non-strings are returned unchanged. +func templateValue(v any, scope vars.Scope) (any, error) { + switch t := v.(type) { + case string: + return tmplString(t, scope) + case map[string]any: + out := make(map[string]any, len(t)) + + for k, val := range t { + rendered, err := templateValue(val, scope) + if err != nil { + return nil, fmt.Errorf("%s: %w", k, err) + } + + out[k] = rendered + } + + return out, nil + case []any: + out := make([]any, len(t)) + + for i, val := range t { + rendered, err := templateValue(val, scope) + if err != nil { + return nil, err + } + + out[i] = rendered + } + + return out, nil + default: + return v, nil + } +} diff --git a/render/helm_test.go b/render/helm_test.go new file mode 100644 index 0000000..abe38a0 --- /dev/null +++ b/render/helm_test.go @@ -0,0 +1,56 @@ +package render + +import ( + "testing" + + "github.com/xraph/ctrlplane/provider" +) + +func TestRender_HelmValues(t *testing.T) { + src := provider.DeploymentSource{ + Type: provider.SourceHelm, + Helm: &provider.HelmSource{ + Repo: "oci://registry/charts", + Chart: "api", + Version: "1.0.0", + Values: map[string]any{ + "image": map[string]any{"tag": "{{ .var.tag }}", "repo": "ghcr/app"}, + "replicaCount": 3, + "tls": true, + "hosts": []any{"{{ .var.host }}", "static.example.com"}, + }, + }, + } + + out, err := Render(src, scopeWith(map[string]any{"tag": "1.2.3", "host": "api.example.com"})) + if err != nil { + t.Fatalf("render: %v", err) + } + + if out.Type != provider.SourceHelm || out.Helm == nil { + t.Fatalf("unexpected output: %+v", out) + } + + h := out.Helm + if h.Chart != "api" || h.Version != "1.0.0" || h.Repo != "oci://registry/charts" { + t.Errorf("chart coords not carried: %+v", h) + } + + img, ok := h.Values["image"].(map[string]any) + if !ok || img["tag"] != "1.2.3" || img["repo"] != "ghcr/app" { + t.Errorf("nested string leaves not templated: %#v", h.Values["image"]) + } + + if h.Values["replicaCount"] != 3 { + t.Errorf("int leaf altered: %#v", h.Values["replicaCount"]) + } + + if h.Values["tls"] != true { + t.Errorf("bool leaf altered: %#v", h.Values["tls"]) + } + + hosts, ok := h.Values["hosts"].([]any) + if !ok || hosts[0] != "api.example.com" || hosts[1] != "static.example.com" { + t.Errorf("slice leaves not templated: %#v", h.Values["hosts"]) + } +} diff --git a/render/kustomize.go b/render/kustomize.go new file mode 100644 index 0000000..5025a01 --- /dev/null +++ b/render/kustomize.go @@ -0,0 +1,54 @@ +package render + +import ( + "fmt" + "path" + + "sigs.k8s.io/kustomize/api/krusty" + "sigs.k8s.io/kustomize/kyaml/filesys" + + "github.com/xraph/ctrlplane/provider" + "github.com/xraph/ctrlplane/vars" +) + +// renderKustomize templates each kustomization file with the scope, writes +// them into an in-memory filesystem, and runs a kustomize build, returning +// the resulting YAML documents. Variables are substituted before the build +// so values can flow into both resources and the kustomization itself. +func renderKustomize(src *provider.KustomizeSource, scope vars.Scope) ([]string, error) { + fSys := filesys.MakeFsInMemory() + + for filePath, content := range src.Files { + templated, err := tmplString(content, scope) + if err != nil { + return nil, fmt.Errorf("kustomize file %s: %w", filePath, err) + } + + if dir := path.Dir(filePath); dir != "." && dir != "/" { + if err := fSys.MkdirAll(dir); err != nil { + return nil, fmt.Errorf("kustomize mkdir %s: %w", dir, err) + } + } + + if err := fSys.WriteFile(filePath, []byte(templated)); err != nil { + return nil, fmt.Errorf("kustomize write %s: %w", filePath, err) + } + } + + root := src.Root + if root == "" { + root = "/" + } + + resMap, err := krusty.MakeKustomizer(krusty.MakeDefaultOptions()).Run(fSys, root) + if err != nil { + return nil, fmt.Errorf("kustomize build: %w", err) + } + + out, err := resMap.AsYaml() + if err != nil { + return nil, fmt.Errorf("kustomize as yaml: %w", err) + } + + return splitYAMLDocs(out) +} diff --git a/render/manifests.go b/render/manifests.go new file mode 100644 index 0000000..b322e6f --- /dev/null +++ b/render/manifests.go @@ -0,0 +1,84 @@ +package render + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + + utilyaml "k8s.io/apimachinery/pkg/util/yaml" + + "github.com/xraph/ctrlplane/provider" + "github.com/xraph/ctrlplane/vars" +) + +// renderManifests resolves a ManifestSource into concrete YAML documents. +// Inline YAML is templated then split; kustomize sources are templated then +// built in memory (see renderKustomize). +func renderManifests(src *provider.ManifestSource, scope vars.Scope) (*provider.RenderedManifests, error) { + if src.Kustomize != nil { + docs, err := renderKustomize(src.Kustomize, scope) + if err != nil { + return nil, err + } + + return &provider.RenderedManifests{Docs: docs}, nil + } + + rendered, err := tmplString(src.Inline, scope) + if err != nil { + return nil, fmt.Errorf("manifests inline: %w", err) + } + + docs, err := splitYAMLDocs([]byte(rendered)) + if err != nil { + return nil, err + } + + return &provider.RenderedManifests{Docs: docs}, nil +} + +// splitYAMLDocs splits multi-document YAML into individual documents, +// skipping whitespace-only documents. +func splitYAMLDocs(data []byte) ([]string, error) { + reader := utilyaml.NewYAMLReader(bufio.NewReader(bytes.NewReader(data))) + + var docs []string + + for { + doc, err := reader.Read() + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + return nil, fmt.Errorf("split yaml: %w", err) + } + + if isEmptyYAMLDoc(doc) { + continue + } + + docs = append(docs, string(doc)) + } + + return docs, nil +} + +// isEmptyYAMLDoc reports whether a YAML document carries no content — every +// line is blank or a bare "---" separator. The apimachinery reader includes +// the leading separator in each frame, so a separator-only frame is not +// whitespace-empty and must be detected line by line. +func isEmptyYAMLDoc(doc []byte) bool { + for line := range bytes.SplitSeq(doc, []byte("\n")) { + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("---")) { + continue + } + + return false + } + + return true +} diff --git a/render/manifests_test.go b/render/manifests_test.go new file mode 100644 index 0000000..1df0042 --- /dev/null +++ b/render/manifests_test.go @@ -0,0 +1,100 @@ +package render + +import ( + "strings" + "testing" + + "github.com/xraph/ctrlplane/provider" +) + +func TestRender_ManifestsInline(t *testing.T) { + inline := `apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .var.name }} +data: + region: {{ .region }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .var.name }}-svc +` + + src := provider.DeploymentSource{ + Type: provider.SourceManifests, + Manifests: &provider.ManifestSource{Inline: inline}, + } + + out, err := Render(src, scopeWith(map[string]any{"name": "app"})) + if err != nil { + t.Fatalf("render: %v", err) + } + + if out.Type != provider.SourceManifests || out.Manifests == nil { + t.Fatalf("unexpected output: %+v", out) + } + + if len(out.Manifests.Docs) != 2 { + t.Fatalf("docs = %d, want 2: %#v", len(out.Manifests.Docs), out.Manifests.Docs) + } + + if !strings.Contains(out.Manifests.Docs[0], "name: app") || !strings.Contains(out.Manifests.Docs[0], "region: us-east") { + t.Errorf("doc[0] missing substitutions: %q", out.Manifests.Docs[0]) + } + + if !strings.Contains(out.Manifests.Docs[1], "name: app-svc") { + t.Errorf("doc[1] missing substitution: %q", out.Manifests.Docs[1]) + } +} + +func TestRender_ManifestsInline_SkipsEmptyDocs(t *testing.T) { + src := provider.DeploymentSource{ + Type: provider.SourceManifests, + Manifests: &provider.ManifestSource{Inline: "---\nkind: Pod\n---\n---\n"}, + } + + out, err := Render(src, scopeWith(nil)) + if err != nil { + t.Fatalf("render: %v", err) + } + + if len(out.Manifests.Docs) != 1 { + t.Fatalf("docs = %d, want 1: %#v", len(out.Manifests.Docs), out.Manifests.Docs) + } +} + +func TestRender_ManifestsKustomize(t *testing.T) { + files := map[string]string{ + "/kustomization.yaml": "namePrefix: prod-\nresources:\n - deployment.yaml\n", + "/deployment.yaml": "apiVersion: apps/v1\n" + + "kind: Deployment\n" + + "metadata:\n name: app\n" + + "spec:\n replicas: {{ .var.replicas }}\n", + } + + src := provider.DeploymentSource{ + Type: provider.SourceManifests, + Manifests: &provider.ManifestSource{ + Kustomize: &provider.KustomizeSource{Files: files, Root: "/"}, + }, + } + + out, err := Render(src, scopeWith(map[string]any{"replicas": 2})) + if err != nil { + t.Fatalf("render: %v", err) + } + + if out.Manifests == nil || len(out.Manifests.Docs) != 1 { + t.Fatalf("docs = %#v, want 1", out.Manifests) + } + + doc := out.Manifests.Docs[0] + if !strings.Contains(doc, "name: prod-app") { + t.Errorf("kustomize namePrefix not applied: %q", doc) + } + + if !strings.Contains(doc, "replicas: 2") { + t.Errorf("variable not templated before build: %q", doc) + } +} diff --git a/render/render.go b/render/render.go new file mode 100644 index 0000000..ddf93eb --- /dev/null +++ b/render/render.go @@ -0,0 +1,90 @@ +package render + +import ( + "fmt" + "strings" + "text/template" + + ctrlplane "github.com/xraph/ctrlplane" + "github.com/xraph/ctrlplane/provider" + "github.com/xraph/ctrlplane/vars" +) + +// Render resolves a DeploymentSource against a variable scope into a +// concrete RenderedSource the provider can apply. The source's Type +// selects which payload is rendered. +func Render(src provider.DeploymentSource, scope vars.Scope) (provider.RenderedSource, error) { + switch src.Type { + case provider.SourceServices: + services, err := renderServices(src.Services, scope) + if err != nil { + return provider.RenderedSource{}, err + } + + return provider.RenderedSource{Type: provider.SourceServices, Services: services}, nil + case provider.SourceManifests: + manifests, err := renderManifests(src.Manifests, scope) + if err != nil { + return provider.RenderedSource{}, err + } + + return provider.RenderedSource{Type: provider.SourceManifests, Manifests: manifests}, nil + case provider.SourceHelm: + helm, err := renderHelm(src.Helm, scope) + if err != nil { + return provider.RenderedSource{}, err + } + + return provider.RenderedSource{Type: provider.SourceHelm, Helm: helm}, nil + case provider.SourceArgoCD: + argo, err := renderArgo(src.ArgoCD, scope) + if err != nil { + return provider.RenderedSource{}, err + } + + return provider.RenderedSource{Type: provider.SourceArgoCD, ArgoCD: argo}, nil + default: + return provider.RenderedSource{}, fmt.Errorf("%w: %q", ctrlplane.ErrUnsupportedSource, src.Type) + } +} + +// tmplString renders a single template string against the scope. Strings +// without a template action are returned unchanged. A missing key (e.g. an +// undefined or secret-typed variable) is an error. +func tmplString(s string, scope vars.Scope) (string, error) { + if !strings.Contains(s, "{{") { + return s, nil + } + + tmpl, err := template.New("r").Option("missingkey=error").Parse(s) + if err != nil { + return "", fmt.Errorf("parse template %q: %w", s, err) + } + + var sb strings.Builder + if err := tmpl.Execute(&sb, scope.Root()); err != nil { + return "", fmt.Errorf("render template %q: %w", s, err) + } + + return sb.String(), nil +} + +// tmplStrings renders each element of a string slice, returning a new slice. +func tmplStrings(in []string, scope vars.Scope) ([]string, error) { + if len(in) == 0 { + return in, nil + } + + out := make([]string, len(in)) + + for i, s := range in { + rendered, err := tmplString(s, scope) + if err != nil { + return nil, err + } + + out[i] = rendered + } + + return out, nil +} diff --git a/render/services.go b/render/services.go new file mode 100644 index 0000000..36cd7d8 --- /dev/null +++ b/render/services.go @@ -0,0 +1,58 @@ +package render + +import ( + "fmt" + + "github.com/xraph/ctrlplane/provider" + "github.com/xraph/ctrlplane/vars" +) + +// renderServices templates the variable-bearing fields of each ServiceSpec +// (Image, Env values, Command, Args), returning new specs. Fields not +// templated (Ports, Volumes, etc.) are carried through unchanged. +func renderServices(in []provider.ServiceSpec, scope vars.Scope) ([]provider.ServiceSpec, error) { + out := make([]provider.ServiceSpec, len(in)) + + for i := range in { + svc := in[i] + + image, err := tmplString(svc.Image, scope) + if err != nil { + return nil, fmt.Errorf("service %s image: %w", svc.Name, err) + } + + svc.Image = image + + if len(svc.Env) > 0 { + env := make(map[string]string, len(svc.Env)) + + for k, v := range svc.Env { + rendered, err := tmplString(v, scope) + if err != nil { + return nil, fmt.Errorf("service %s env %s: %w", svc.Name, k, err) + } + + env[k] = rendered + } + + svc.Env = env + } + + command, err := tmplStrings(svc.Command, scope) + if err != nil { + return nil, fmt.Errorf("service %s command: %w", svc.Name, err) + } + + svc.Command = command + + args, err := tmplStrings(svc.Args, scope) + if err != nil { + return nil, fmt.Errorf("service %s args: %w", svc.Name, err) + } + + svc.Args = args + out[i] = svc + } + + return out, nil +} diff --git a/render/services_test.go b/render/services_test.go new file mode 100644 index 0000000..e917339 --- /dev/null +++ b/render/services_test.go @@ -0,0 +1,67 @@ +package render + +import ( + "testing" + + "github.com/xraph/ctrlplane/provider" + "github.com/xraph/ctrlplane/vars" +) + +func scopeWith(v map[string]any) vars.Scope { + return vars.Scope{ + Var: v, + Instance: vars.InstanceContext{ID: "inst_1", Name: "web"}, + Tenant: vars.TenantContext{ID: "tnt_1"}, + Region: "us-east", + } +} + +func TestRender_Services(t *testing.T) { + src := provider.DeploymentSource{ + Type: provider.SourceServices, + Services: []provider.ServiceSpec{{ + Name: "web", + Image: "nginx:{{ .var.tag }}", + Env: map[string]string{"REGION": "{{ .region }}", "STATIC": "x"}, + Command: []string{"run", "--name={{ .instance.name }}"}, + Args: []string{"--tenant={{ .tenant.id }}"}, + }}, + } + + out, err := Render(src, scopeWith(map[string]any{"tag": "1.25"})) + if err != nil { + t.Fatalf("render: %v", err) + } + + if out.Type != provider.SourceServices || len(out.Services) != 1 { + t.Fatalf("unexpected output: %+v", out) + } + + svc := out.Services[0] + if svc.Image != "nginx:1.25" { + t.Errorf("image = %q, want nginx:1.25", svc.Image) + } + + if svc.Env["REGION"] != "us-east" || svc.Env["STATIC"] != "x" { + t.Errorf("env = %v", svc.Env) + } + + if svc.Command[1] != "--name=web" { + t.Errorf("command = %v", svc.Command) + } + + if svc.Args[0] != "--tenant=tnt_1" { + t.Errorf("args = %v", svc.Args) + } +} + +func TestRender_Services_MissingVar(t *testing.T) { + src := provider.DeploymentSource{ + Type: provider.SourceServices, + Services: []provider.ServiceSpec{{Name: "web", Image: "nginx:{{ .var.absent }}"}}, + } + + if _, err := Render(src, scopeWith(nil)); err == nil { + t.Fatal("expected error for missing variable, got nil") + } +} diff --git a/store/badger/template.go b/store/badger/template.go index e4be91b..bf32f1e 100644 --- a/store/badger/template.go +++ b/store/badger/template.go @@ -52,6 +52,10 @@ func (s *Store) GetTemplate(_ context.Context, tenantID string, templateID id.ID return nil, err } + // Legacy documents predate Source — normalize so callers always see a + // populated Source. + t.NormalizeSource() + return &t, nil } @@ -104,6 +108,7 @@ func (s *Store) ListTemplates(_ context.Context, tenantID string, opts template. return nil } + t.NormalizeSource() items = append(items, &t) return nil diff --git a/store/memory/template.go b/store/memory/template.go index 407849f..6adab92 100644 --- a/store/memory/template.go +++ b/store/memory/template.go @@ -122,5 +122,9 @@ func cloneTemplate(t *template.Template) *template.Template { clone.Services = slices.Clone(t.Services) } + if t.Variables != nil { + clone.Variables = slices.Clone(t.Variables) + } + return &clone } diff --git a/store/memory/template_source_test.go b/store/memory/template_source_test.go new file mode 100644 index 0000000..e14810f --- /dev/null +++ b/store/memory/template_source_test.go @@ -0,0 +1,59 @@ +package memory + +import ( + "context" + "testing" + + ctrlplane "github.com/xraph/ctrlplane" + "github.com/xraph/ctrlplane/id" + "github.com/xraph/ctrlplane/provider" + "github.com/xraph/ctrlplane/template" + "github.com/xraph/ctrlplane/vars" +) + +// TestTemplateStore_VariablesAndSourceRoundTrip confirms a template's +// Variables and Source survive persistence and that the stored copy is +// isolated from later mutation of the caller's slice. +func TestTemplateStore_VariablesAndSourceRoundTrip(t *testing.T) { + t.Parallel() + + store := New() + ctx := context.Background() + + tmpl := &template.Template{ + Entity: ctrlplane.NewEntity(id.PrefixTemplate), + TenantID: "ten_1", + Name: "t1", + Variables: []vars.Definition{ + {Name: "tag", Type: vars.TypeString, Default: "latest"}, + }, + Source: provider.DeploymentSource{ + Type: provider.SourceHelm, + Helm: &provider.HelmSource{Chart: "redis"}, + }, + } + + if err := store.InsertTemplate(ctx, tmpl); err != nil { + t.Fatalf("insert: %v", err) + } + + // Mutate the caller's slice after insert — the stored copy must not change. + tmpl.Variables[0].Default = "MUTATED" + + got, err := store.GetTemplate(ctx, "ten_1", tmpl.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + + if got.Source.Type != provider.SourceHelm || got.Source.Helm == nil || got.Source.Helm.Chart != "redis" { + t.Errorf("source not preserved: %+v", got.Source) + } + + if len(got.Variables) != 1 { + t.Fatalf("variables = %d, want 1", len(got.Variables)) + } + + if got.Variables[0].Default != "latest" { + t.Errorf("variable default = %v, want latest (stored copy not isolated)", got.Variables[0].Default) + } +} diff --git a/store/mongo/models.go b/store/mongo/models.go index c6c0989..f210fb4 100644 --- a/store/mongo/models.go +++ b/store/mongo/models.go @@ -16,6 +16,7 @@ import ( "github.com/xraph/ctrlplane/secrets" "github.com/xraph/ctrlplane/telemetry" "github.com/xraph/ctrlplane/template" + "github.com/xraph/ctrlplane/vars" ctrlplane "github.com/xraph/ctrlplane" "github.com/xraph/ctrlplane/provider" @@ -68,21 +69,22 @@ func fromTenantModel(m *tenantModel) *admin.Tenant { type instanceModel struct { grove.BaseModel `grove:"table:cp_instances"` - ID string `bson:"_id" grove:"id,pk"` - TenantID string `bson:"tenant_id" grove:"tenant_id"` - Slug string `bson:"slug" grove:"slug"` - Name string `bson:"name" grove:"name"` - State string `bson:"state" grove:"state"` - ProviderName string `bson:"provider_name" grove:"provider_name"` - ProviderRef string `bson:"provider_ref,omitempty" grove:"provider_ref"` - Region string `bson:"region,omitempty" grove:"region"` - Kind string `bson:"kind,omitempty" grove:"kind"` - Services []provider.ServiceSpec `bson:"services,omitempty" grove:"services"` - ServiceRefs map[string]string `bson:"service_refs,omitempty" grove:"service_refs"` - Endpoints []endpointModel `bson:"endpoints,omitempty" grove:"endpoints"` - Labels map[string]string `bson:"labels,omitempty" grove:"labels"` - CreatedAt time.Time `bson:"created_at" grove:"created_at"` - UpdatedAt time.Time `bson:"updated_at" grove:"updated_at"` + ID string `bson:"_id" grove:"id,pk"` + TenantID string `bson:"tenant_id" grove:"tenant_id"` + Slug string `bson:"slug" grove:"slug"` + Name string `bson:"name" grove:"name"` + State string `bson:"state" grove:"state"` + ProviderName string `bson:"provider_name" grove:"provider_name"` + ProviderRef string `bson:"provider_ref,omitempty" grove:"provider_ref"` + Region string `bson:"region,omitempty" grove:"region"` + Kind string `bson:"kind,omitempty" grove:"kind"` + Services []provider.ServiceSpec `bson:"services,omitempty" grove:"services"` + ServiceRefs map[string]string `bson:"service_refs,omitempty" grove:"service_refs"` + Endpoints []endpointModel `bson:"endpoints,omitempty" grove:"endpoints"` + Labels map[string]string `bson:"labels,omitempty" grove:"labels"` + Source provider.DeploymentSource `bson:"source,omitempty"` + CreatedAt time.Time `bson:"created_at" grove:"created_at"` + UpdatedAt time.Time `bson:"updated_at" grove:"updated_at"` } // endpointModel is the bson form of provider.Endpoint. @@ -109,6 +111,7 @@ func toInstanceModel(inst *instance.Instance) *instanceModel { ServiceRefs: inst.ServiceRefs, Endpoints: toEndpointModels(inst.Endpoints), Labels: inst.Labels, + Source: inst.Source, CreatedAt: inst.CreatedAt, UpdatedAt: inst.UpdatedAt, } @@ -133,6 +136,7 @@ func fromInstanceModel(m *instanceModel) *instance.Instance { ServiceRefs: m.ServiceRefs, Endpoints: fromEndpointModels(m.Endpoints), Labels: m.Labels, + Source: m.Source, } return out @@ -767,17 +771,19 @@ func fromAuditEntryModel(m *auditEntryModel) admin.AuditEntry { type templateModel struct { grove.BaseModel `grove:"table:cp_templates"` - ID string `bson:"_id" grove:"id,pk"` - TenantID string `bson:"tenant_id" grove:"tenant_id"` - Name string `bson:"name" grove:"name"` - Description string `bson:"description,omitempty" grove:"description"` - DefaultKind string `bson:"default_kind,omitempty" grove:"default_kind"` - DefaultStrategy string `bson:"default_strategy,omitempty" grove:"default_strategy"` - Services []provider.ServiceSpec `bson:"services,omitempty"` - Labels map[string]string `bson:"labels,omitempty"` - Notes string `bson:"notes,omitempty" grove:"notes"` - CreatedAt time.Time `bson:"created_at" grove:"created_at"` - UpdatedAt time.Time `bson:"updated_at" grove:"updated_at"` + ID string `bson:"_id" grove:"id,pk"` + TenantID string `bson:"tenant_id" grove:"tenant_id"` + Name string `bson:"name" grove:"name"` + Description string `bson:"description,omitempty" grove:"description"` + DefaultKind string `bson:"default_kind,omitempty" grove:"default_kind"` + DefaultStrategy string `bson:"default_strategy,omitempty" grove:"default_strategy"` + Services []provider.ServiceSpec `bson:"services,omitempty"` + Labels map[string]string `bson:"labels,omitempty"` + Notes string `bson:"notes,omitempty" grove:"notes"` + Variables []vars.Definition `bson:"variables,omitempty"` + Source provider.DeploymentSource `bson:"source,omitempty"` + CreatedAt time.Time `bson:"created_at" grove:"created_at"` + UpdatedAt time.Time `bson:"updated_at" grove:"updated_at"` } func toTemplateModel(t *template.Template) *templateModel { @@ -791,13 +797,15 @@ func toTemplateModel(t *template.Template) *templateModel { Services: t.Services, Labels: t.Labels, Notes: t.Notes, + Variables: t.Variables, + Source: t.Source, CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt, } } func fromTemplateModel(m *templateModel) *template.Template { - return &template.Template{ + t := &template.Template{ Entity: ctrlplane.Entity{ ID: id.MustParse(m.ID), CreatedAt: m.CreatedAt, @@ -811,7 +819,15 @@ func fromTemplateModel(m *templateModel) *template.Template { Services: m.Services, Labels: m.Labels, Notes: m.Notes, + Variables: m.Variables, + Source: m.Source, } + + // Legacy documents predate Source — project Services onto a services + // Source so callers always see a populated Source. + t.NormalizeSource() + + return t } // ── Datacenter ────────────────────────────────────────────────────────────── diff --git a/store/postgres/models.go b/store/postgres/models.go index 7e4a4da..0a07fdf 100644 --- a/store/postgres/models.go +++ b/store/postgres/models.go @@ -54,6 +54,7 @@ type instanceModel struct { Config []byte `grove:"config,type:jsonb"` Metadata []byte `grove:"metadata,type:jsonb"` Endpoints []byte `grove:"endpoints,type:jsonb"` + Source []byte `grove:"source,type:jsonb"` CreatedAt time.Time `grove:"created_at,notnull"` UpdatedAt time.Time `grove:"updated_at,notnull"` } @@ -277,6 +278,8 @@ type templateModel struct { Services []byte `grove:"services,type:jsonb"` Labels []byte `grove:"labels,type:jsonb"` Notes string `grove:"notes"` + Variables []byte `grove:"variables,type:jsonb"` + Source []byte `grove:"source,type:jsonb"` CreatedAt time.Time `grove:"created_at,notnull"` UpdatedAt time.Time `grove:"updated_at,notnull"` } @@ -298,6 +301,7 @@ func toInstanceModel(inst *instance.Instance) *instanceModel { ServiceRefs: marshalJSONB(inst.ServiceRefs), Labels: marshalJSONB(inst.Labels), Endpoints: marshalJSONB(inst.Endpoints), + Source: marshalJSONB(inst.Source), CreatedAt: inst.CreatedAt, UpdatedAt: inst.UpdatedAt, } @@ -324,6 +328,7 @@ func fromInstanceModel(m *instanceModel) *instance.Instance { unmarshalJSONB(m.ServiceRefs, &out.ServiceRefs) unmarshalJSONB(m.Labels, &out.Labels) unmarshalJSONB(m.Endpoints, &out.Endpoints) + unmarshalJSONB(m.Source, &out.Source) return out } @@ -574,6 +579,8 @@ func toTemplateModel(t *template.Template) *templateModel { Services: marshalJSONB(t.Services), Labels: marshalJSONB(t.Labels), Notes: t.Notes, + Variables: marshalJSONB(t.Variables), + Source: marshalJSONB(t.Source), CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt, } @@ -596,6 +603,12 @@ func fromTemplateModel(m *templateModel) *template.Template { unmarshalJSONB(m.Services, &t.Services) unmarshalJSONB(m.Labels, &t.Labels) + unmarshalJSONB(m.Variables, &t.Variables) + unmarshalJSONB(m.Source, &t.Source) + + // Legacy rows predate the Source column — project their Services onto a + // services Source so callers always see a populated Source. + t.NormalizeSource() return t } diff --git a/store/sqlite/models.go b/store/sqlite/models.go index 492cf23..06a2158 100644 --- a/store/sqlite/models.go +++ b/store/sqlite/models.go @@ -54,6 +54,7 @@ type instanceModel struct { Endpoints []byte `grove:"endpoints"` Config []byte `grove:"config"` Metadata []byte `grove:"metadata"` + Source []byte `grove:"source"` CreatedAt time.Time `grove:"created_at,notnull"` UpdatedAt time.Time `grove:"updated_at,notnull"` } @@ -277,6 +278,8 @@ type templateModel struct { Services []byte `grove:"services"` Labels []byte `grove:"labels"` Notes string `grove:"notes"` + Variables []byte `grove:"variables"` + Source []byte `grove:"source"` CreatedAt time.Time `grove:"created_at,notnull"` UpdatedAt time.Time `grove:"updated_at,notnull"` } @@ -298,6 +301,7 @@ func toInstanceModel(inst *instance.Instance) *instanceModel { ServiceRefs: marshalJSON(inst.ServiceRefs), Labels: marshalJSON(inst.Labels), Endpoints: marshalJSON(inst.Endpoints), + Source: marshalJSON(inst.Source), CreatedAt: inst.CreatedAt, UpdatedAt: inst.UpdatedAt, } @@ -324,6 +328,7 @@ func fromInstanceModel(m *instanceModel) *instance.Instance { unmarshalJSON(m.ServiceRefs, &out.ServiceRefs) unmarshalJSON(m.Labels, &out.Labels) unmarshalJSON(m.Endpoints, &out.Endpoints) + unmarshalJSON(m.Source, &out.Source) return out } @@ -624,6 +629,8 @@ func toTemplateModel(t *template.Template) *templateModel { Services: marshalJSON(t.Services), Labels: marshalJSON(t.Labels), Notes: t.Notes, + Variables: marshalJSON(t.Variables), + Source: marshalJSON(t.Source), CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt, } @@ -646,6 +653,12 @@ func fromTemplateModel(m *templateModel) *template.Template { unmarshalJSON(m.Services, &t.Services) unmarshalJSON(m.Labels, &t.Labels) + unmarshalJSON(m.Variables, &t.Variables) + unmarshalJSON(m.Source, &t.Source) + + // Legacy rows predate the Source column — project Services onto a + // services Source so callers always see a populated Source. + t.NormalizeSource() return t } diff --git a/template/service_impl.go b/template/service_impl.go index 8e60082..9d6d300 100644 --- a/template/service_impl.go +++ b/template/service_impl.go @@ -11,6 +11,7 @@ import ( "github.com/xraph/ctrlplane/event" "github.com/xraph/ctrlplane/id" "github.com/xraph/ctrlplane/provider" + "github.com/xraph/ctrlplane/vars" ) // service is the concrete Service implementation. @@ -60,7 +61,28 @@ func (s *service) Create(ctx context.Context, req CreateRequest) (*Template, err return nil, errors.New("create template: name is required") } - if err := validateServices(req.Services); err != nil { + // Resolve the effective deployment source: an explicit Source, or + // legacy Services projected onto a services Source. + source := req.Source + if source.Type == "" && len(req.Services) > 0 { + source = provider.DeploymentSource{Type: provider.SourceServices, Services: req.Services} + } + + if source.Type == "" { + return nil, errors.New("create template: a source or services is required") + } + + if err := source.Validate(); err != nil { + return nil, fmt.Errorf("create template: %w", err) + } + + if source.Type == provider.SourceServices { + if err := validateServices(source.Services); err != nil { + return nil, fmt.Errorf("create template: %w", err) + } + } + + if err := vars.ValidateDefinitions(req.Variables); err != nil { return nil, fmt.Errorf("create template: %w", err) } @@ -76,9 +98,11 @@ func (s *service) Create(ctx context.Context, req CreateRequest) (*Template, err Description: req.Description, DefaultKind: kind, DefaultStrategy: req.DefaultStrategy, - Services: req.Services, + Services: source.Services, Labels: req.Labels, Notes: req.Notes, + Variables: req.Variables, + Source: source, } if err := s.store.InsertTemplate(ctx, tmpl); err != nil { @@ -207,6 +231,34 @@ func (s *service) Update(ctx context.Context, templateID id.ID, req UpdateReques tmpl.Notes = *req.Notes } + if req.Variables != nil { + if err := vars.ValidateDefinitions(req.Variables); err != nil { + return nil, fmt.Errorf("update template: %w", err) + } + + tmpl.Variables = req.Variables + } + + if req.Source != nil { + if err := req.Source.Validate(); err != nil { + return nil, fmt.Errorf("update template: %w", err) + } + + if req.Source.Type == provider.SourceServices { + if err := validateServices(req.Source.Services); err != nil { + return nil, fmt.Errorf("update template: %w", err) + } + } + + tmpl.Source = *req.Source + tmpl.Services = req.Source.Services + } else if req.Services != nil && tmpl.Source.Type == provider.SourceServices { + // Keep a services Source in sync with a legacy Services update. + tmpl.Source.Services = req.Services + } + + tmpl.NormalizeSource() + if err := s.store.UpdateTemplate(ctx, tmpl); err != nil { return nil, fmt.Errorf("update template %s: %w", templateID, err) } diff --git a/template/source_create_test.go b/template/source_create_test.go new file mode 100644 index 0000000..d6183b5 --- /dev/null +++ b/template/source_create_test.go @@ -0,0 +1,76 @@ +package template + +import ( + "errors" + "testing" + + ctrlplane "github.com/xraph/ctrlplane" + "github.com/xraph/ctrlplane/provider" + "github.com/xraph/ctrlplane/vars" +) + +func TestCreate_HelmSource(t *testing.T) { + svc := NewService(newMemStore(), nil) + ctx := tenantCtx("ten_1") + + tmpl, err := svc.Create(ctx, CreateRequest{ + Name: "redis", + Source: provider.DeploymentSource{Type: provider.SourceHelm, Helm: &provider.HelmSource{Chart: "redis"}}, + Variables: []vars.Definition{{Name: "tag", Type: vars.TypeString, Default: "latest"}}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + if tmpl.Source.Type != provider.SourceHelm || tmpl.Source.Helm == nil || tmpl.Source.Helm.Chart != "redis" { + t.Errorf("source not stored: %+v", tmpl.Source) + } + + if len(tmpl.Variables) != 1 || tmpl.Variables[0].Name != "tag" { + t.Errorf("variables not stored: %+v", tmpl.Variables) + } +} + +func TestCreate_HelmSourceInvalid(t *testing.T) { + svc := NewService(newMemStore(), nil) + ctx := tenantCtx("ten_1") + + _, err := svc.Create(ctx, CreateRequest{ + Name: "redis", + Source: provider.DeploymentSource{Type: provider.SourceHelm, Helm: &provider.HelmSource{}}, + }) + if !errors.Is(err, ctrlplane.ErrInvalidSource) { + t.Fatalf("expected ErrInvalidSource, got %v", err) + } +} + +func TestCreate_InvalidVariable(t *testing.T) { + svc := NewService(newMemStore(), nil) + ctx := tenantCtx("ten_1") + + _, err := svc.Create(ctx, CreateRequest{ + Name: "redis", + Source: provider.DeploymentSource{Type: provider.SourceHelm, Helm: &provider.HelmSource{Chart: "redis"}}, + Variables: []vars.Definition{{Name: "bad", Type: vars.TypeEnum}}, // enum without members + }) + if !errors.Is(err, vars.ErrInvalidDefinition) { + t.Fatalf("expected ErrInvalidDefinition, got %v", err) + } +} + +func TestCreate_LegacyServicesNormalizes(t *testing.T) { + svc := NewService(newMemStore(), nil) + ctx := tenantCtx("ten_1") + + tmpl, err := svc.Create(ctx, CreateRequest{ + Name: "web", + Services: []provider.ServiceSpec{{Name: "web", Image: "nginx"}}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + if tmpl.Source.Type != provider.SourceServices || len(tmpl.Source.Services) != 1 { + t.Errorf("legacy services not normalized to source: %+v", tmpl.Source) + } +} diff --git a/template/source_test.go b/template/source_test.go new file mode 100644 index 0000000..3d91b42 --- /dev/null +++ b/template/source_test.go @@ -0,0 +1,49 @@ +package template + +import ( + "testing" + + "github.com/xraph/ctrlplane/provider" +) + +func TestTemplate_NormalizeSource_LegacyServices(t *testing.T) { + tmpl := &Template{ + Services: []provider.ServiceSpec{{Name: "web", Image: "nginx"}}, + } + + tmpl.NormalizeSource() + + if tmpl.Source.Type != provider.SourceServices { + t.Errorf("type = %q, want services", tmpl.Source.Type) + } + + if len(tmpl.Source.Services) != 1 || tmpl.Source.Services[0].Name != "web" { + t.Errorf("services not carried into source: %+v", tmpl.Source.Services) + } +} + +func TestTemplate_NormalizeSource_ExplicitUnchanged(t *testing.T) { + tmpl := &Template{ + Source: provider.DeploymentSource{ + Type: provider.SourceHelm, + Helm: &provider.HelmSource{Chart: "redis"}, + }, + } + + tmpl.NormalizeSource() + + if tmpl.Source.Type != provider.SourceHelm || tmpl.Source.Helm == nil || tmpl.Source.Helm.Chart != "redis" { + t.Errorf("explicit source changed: %+v", tmpl.Source) + } +} + +func TestTemplate_NormalizeSource_Idempotent(t *testing.T) { + tmpl := &Template{Services: []provider.ServiceSpec{{Name: "web", Image: "nginx"}}} + + tmpl.NormalizeSource() + tmpl.NormalizeSource() + + if tmpl.Source.Type != provider.SourceServices || len(tmpl.Source.Services) != 1 { + t.Errorf("normalize not idempotent: %+v", tmpl.Source) + } +} diff --git a/template/template.go b/template/template.go index f04d3fd..f440f51 100644 --- a/template/template.go +++ b/template/template.go @@ -3,6 +3,7 @@ package template import ( ctrlplane "github.com/xraph/ctrlplane" "github.com/xraph/ctrlplane/provider" + "github.com/xraph/ctrlplane/vars" ) // SecretRef is an alias for provider.SecretRef. The canonical type lives @@ -32,6 +33,27 @@ type Template struct { Services []provider.ServiceSpec `db:"services" json:"services"` Labels map[string]string `db:"labels" json:"labels,omitempty"` Notes string `db:"notes" json:"notes,omitempty"` + + // Variables declares the typed template variables resolved at + // instantiation time and injected into the deployment source. + Variables []vars.Definition `db:"variables" json:"variables,omitempty"` + + // Source describes what the template deploys (services | helm | + // manifests | argocd). Legacy templates carry only Services; call + // NormalizeSource to project them onto a services Source. + Source provider.DeploymentSource `db:"source" json:"source,omitzero"` +} + +// NormalizeSource projects a legacy services-only template onto a typed +// services Source when no Source.Type is set. Idempotent — an explicit +// Source is left untouched. +func (t *Template) NormalizeSource() { + if t.Source.Type == "" && len(t.Services) > 0 { + t.Source = provider.DeploymentSource{ + Type: provider.SourceServices, + Services: t.Services, + } + } } // MainService returns the template's Main service, or nil when none is diff --git a/template/types.go b/template/types.go index d5f22a5..c677a78 100644 --- a/template/types.go +++ b/template/types.go @@ -5,29 +5,38 @@ import ( "github.com/xraph/ctrlplane/id" "github.com/xraph/ctrlplane/provider" + "github.com/xraph/ctrlplane/vars" ) // CreateRequest holds the parameters for creating a workload template. +// +// A template describes what to deploy via Source. For backward +// compatibility, callers may instead populate Services alone — Create +// projects them onto a services Source. type CreateRequest struct { - Name string `json:"name" validate:"required"` - Description string `json:"description,omitempty"` - DefaultKind provider.WorkloadKind `json:"default_kind,omitempty"` - DefaultStrategy string `json:"default_strategy,omitempty"` - Services []provider.ServiceSpec `json:"services" validate:"required,min=1"` - Labels map[string]string `json:"labels,omitempty"` - Notes string `json:"notes,omitempty"` + Name string `json:"name" validate:"required"` + Description string `json:"description,omitempty"` + DefaultKind provider.WorkloadKind `json:"default_kind,omitempty"` + DefaultStrategy string `json:"default_strategy,omitempty"` + Services []provider.ServiceSpec `json:"services,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Notes string `json:"notes,omitempty"` + Variables []vars.Definition `json:"variables,omitempty"` + Source provider.DeploymentSource `json:"source,omitzero"` } // UpdateRequest holds the parameters for updating a workload template. // Pointer fields enable partial updates — only non-nil fields are applied. type UpdateRequest struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - DefaultKind *provider.WorkloadKind `json:"default_kind,omitempty"` - DefaultStrategy *string `json:"default_strategy,omitempty"` - Services []provider.ServiceSpec `json:"services,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - Notes *string `json:"notes,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + DefaultKind *provider.WorkloadKind `json:"default_kind,omitempty"` + DefaultStrategy *string `json:"default_strategy,omitempty"` + Services []provider.ServiceSpec `json:"services,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Notes *string `json:"notes,omitempty"` + Variables []vars.Definition `json:"variables,omitempty"` + Source *provider.DeploymentSource `json:"source,omitempty"` } // CreateFromWorkloadRequest forks a template from an existing workload's diff --git a/vars/doc.go b/vars/doc.go new file mode 100644 index 0000000..0b078bd --- /dev/null +++ b/vars/doc.go @@ -0,0 +1,10 @@ +// Package vars defines first-class, typed template variables and the +// resolver that turns variable definitions plus caller-supplied values +// into a resolution scope used to render deployment sources. +// +// Variables may be plain (string/int/bool/enum) with defaults and +// validation, secret-typed (resolved to a +// [github.com/xraph/ctrlplane/provider.SecretBinding] and never inlined +// into rendered output), or computed from a Go text/template expression +// over previously-resolved variables and derived context. +package vars diff --git a/vars/errors.go b/vars/errors.go new file mode 100644 index 0000000..d6f0d63 --- /dev/null +++ b/vars/errors.go @@ -0,0 +1,18 @@ +package vars + +import "errors" + +var ( + // ErrInvalidDefinition indicates a malformed variable definition. + ErrInvalidDefinition = errors.New("vars: invalid variable definition") + + // ErrMissingRequired indicates a required variable had no value or default. + ErrMissingRequired = errors.New("vars: required variable not provided") + + // ErrInvalidValue indicates a value failed type, enum, or pattern validation. + ErrInvalidValue = errors.New("vars: invalid variable value") + + // ErrCycle indicates computed variables form an unresolvable cycle or + // reference a variable that is never defined. + ErrCycle = errors.New("vars: computed variable cycle or undefined reference") +) diff --git a/vars/resolver.go b/vars/resolver.go new file mode 100644 index 0000000..4990d80 --- /dev/null +++ b/vars/resolver.go @@ -0,0 +1,319 @@ +package vars + +import ( + "context" + "fmt" + "regexp" + "slices" + "strconv" + "strings" + "text/template" + + "github.com/xraph/ctrlplane/provider" +) + +// Resolver turns variable definitions and caller-supplied values into a +// resolved Scope plus the secret bindings the provider must materialize. +type Resolver struct{} + +// NewResolver returns a Resolver. +func NewResolver() *Resolver { + return &Resolver{} +} + +// Resolve validates definitions, applies values and defaults, evaluates +// computed expressions, and collects secret bindings. derived seeds the +// instance, tenant, and region context. The returned Scope's Var map holds +// every plain and computed variable; secret variables are excluded from Var +// and returned as bindings. +func (r *Resolver) Resolve( + _ context.Context, + defs []Definition, + values map[string]any, + derived Scope, +) (Scope, []provider.SecretBinding, error) { + scope := Scope{ + Var: make(map[string]any, len(defs)), + Instance: derived.Instance, + Tenant: derived.Tenant, + Region: derived.Region, + } + + var ( + bindings []provider.SecretBinding + computed []Definition + ) + + seen := make(map[string]struct{}, len(defs)) + + for _, def := range defs { + if err := validateDefinition(def); err != nil { + return Scope{}, nil, err + } + + if _, dup := seen[def.Name]; dup { + return Scope{}, nil, fmt.Errorf("%w: duplicate variable %q", ErrInvalidDefinition, def.Name) + } + + seen[def.Name] = struct{}{} + + switch { + case def.Type == TypeSecret: + bindings = append(bindings, provider.SecretBinding{ + VarName: def.Name, + EnvKey: secretEnvKey(def), + Ref: *def.Secret, + }) + case def.Expression != "": + computed = append(computed, def) + default: + val, err := resolvePlain(def, values[def.Name]) + if err != nil { + return Scope{}, nil, err + } + + if val != nil { + scope.Var[def.Name] = val + } + } + } + + if err := resolveComputed(scope, computed); err != nil { + return Scope{}, nil, err + } + + return scope, bindings, nil +} + +// ValidateDefinitions checks a set of variable definitions for structural +// validity and duplicate names without resolving any values. Useful for +// validating a template's variables at author time. +func ValidateDefinitions(defs []Definition) error { + seen := make(map[string]struct{}, len(defs)) + + for _, def := range defs { + if err := validateDefinition(def); err != nil { + return err + } + + if _, dup := seen[def.Name]; dup { + return fmt.Errorf("%w: duplicate variable %q", ErrInvalidDefinition, def.Name) + } + + seen[def.Name] = struct{}{} + } + + return nil +} + +// validateDefinition enforces structural invariants independent of values. +func validateDefinition(def Definition) error { + if strings.TrimSpace(def.Name) == "" { + return fmt.Errorf("%w: empty name", ErrInvalidDefinition) + } + + if def.Default != nil && def.Expression != "" { + return fmt.Errorf("%w: %q sets both default and expression", ErrInvalidDefinition, def.Name) + } + + switch def.Type { + case TypeString, TypeInt, TypeBool: + case TypeEnum: + if len(def.Enum) == 0 { + return fmt.Errorf("%w: enum variable %q has no members", ErrInvalidDefinition, def.Name) + } + case TypeSecret: + if def.Secret == nil { + return fmt.Errorf("%w: secret variable %q has no source", ErrInvalidDefinition, def.Name) + } + + if def.Expression != "" { + return fmt.Errorf("%w: secret variable %q cannot be computed", ErrInvalidDefinition, def.Name) + } + default: + return fmt.Errorf("%w: %q has unknown type %q", ErrInvalidDefinition, def.Name, def.Type) + } + + if def.Pattern != "" { + if _, err := regexp.Compile(def.Pattern); err != nil { + return fmt.Errorf("%w: %q has invalid pattern %q: %w", ErrInvalidDefinition, def.Name, def.Pattern, err) + } + } + + if def.Expression != "" { + if _, err := template.New(def.Name).Parse(def.Expression); err != nil { + return fmt.Errorf("%w: %q has invalid expression: %w", ErrInvalidDefinition, def.Name, err) + } + } + + return nil +} + +// resolvePlain applies a value or default and validates by type. A nil +// return with a nil error means the variable is optional and unset, so the +// caller omits it from the scope. +func resolvePlain(def Definition, raw any) (any, error) { + if raw == nil { + raw = def.Default + } + + if raw == nil { + if def.Required { + return nil, fmt.Errorf("%w: %q", ErrMissingRequired, def.Name) + } + + return nil, nil //nolint:nilnil // (nil, nil) intentionally signals "optional unset" + } + + switch def.Type { + case TypeString, TypeEnum: + s, ok := raw.(string) + if !ok { + return nil, fmt.Errorf("%w: %q expects string, got %T", ErrInvalidValue, def.Name, raw) + } + + if def.Type == TypeEnum && !slices.Contains(def.Enum, s) { + return nil, fmt.Errorf("%w: %q=%q not in enum %v", ErrInvalidValue, def.Name, s, def.Enum) + } + + if err := matchPattern(def, s); err != nil { + return nil, err + } + + return s, nil + case TypeInt: + n, err := toInt(raw) + if err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidValue, def.Name, err) + } + + return n, nil + case TypeBool: + b, err := toBool(raw) + if err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidValue, def.Name, err) + } + + return b, nil + default: + return nil, fmt.Errorf("%w: %q has unsupported type %q", ErrInvalidValue, def.Name, def.Type) + } +} + +// matchPattern validates s against def.Pattern when one is set. +func matchPattern(def Definition, s string) error { + if def.Pattern == "" { + return nil + } + + re, err := regexp.Compile(def.Pattern) + if err != nil { + return fmt.Errorf("%w: %q pattern %q: %w", ErrInvalidValue, def.Name, def.Pattern, err) + } + + if !re.MatchString(s) { + return fmt.Errorf("%w: %q=%q does not match pattern %q", ErrInvalidValue, def.Name, s, def.Pattern) + } + + return nil +} + +// toInt coerces a JSON-decoded or native value to an int. +func toInt(raw any) (int, error) { + switch v := raw.(type) { + case int: + return v, nil + case int64: + return int(v), nil + case float64: // JSON numbers decode to float64 + return int(v), nil + case string: + n, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("not an integer: %q", v) + } + + return n, nil + default: + return 0, fmt.Errorf("expected integer, got %T", raw) + } +} + +// toBool coerces a native or string value to a bool. +func toBool(raw any) (bool, error) { + switch v := raw.(type) { + case bool: + return v, nil + case string: + b, err := strconv.ParseBool(v) + if err != nil { + return false, fmt.Errorf("not a boolean: %q", v) + } + + return b, nil + default: + return false, fmt.Errorf("expected boolean, got %T", raw) + } +} + +// secretEnvKey returns the env-var name a secret binding is exposed as, +// defaulting to UPPER_SNAKE of the variable name. +func secretEnvKey(def Definition) string { + return strings.ToUpper(strings.ReplaceAll(def.Name, "-", "_")) +} + +// resolveComputed evaluates computed expressions via an iterative fixpoint. +// Each round evaluates every still-unresolved expression against the current +// scope; an expression referencing a not-yet-resolved variable fails +// (missingkey=error) and is retried next round. A round that makes no +// progress while expressions remain indicates a cycle or undefined reference. +func resolveComputed(scope Scope, computed []Definition) error { + remaining := slices.Clone(computed) + + for len(remaining) > 0 { + progressed := false + + var next []Definition + + for _, def := range remaining { + val, err := evalExpression(def.Expression, scope) + if err != nil { + next = append(next, def) + + continue + } + + scope.Var[def.Name] = val + progressed = true + } + + if !progressed { + names := make([]string, 0, len(next)) + for _, d := range next { + names = append(names, d.Name) + } + + return fmt.Errorf("%w: %v", ErrCycle, names) + } + + remaining = next + } + + return nil +} + +// evalExpression renders a computed expression against the scope. A missing +// key (unresolved reference) is an error so the fixpoint can retry. +func evalExpression(expr string, scope Scope) (string, error) { + tmpl, err := template.New("expr").Option("missingkey=error").Parse(expr) + if err != nil { + return "", fmt.Errorf("parse expression: %w", err) + } + + var sb strings.Builder + if err := tmpl.Execute(&sb, scope.Root()); err != nil { + return "", fmt.Errorf("eval expression: %w", err) + } + + return sb.String(), nil +} diff --git a/vars/resolver_test.go b/vars/resolver_test.go new file mode 100644 index 0000000..1fc3332 --- /dev/null +++ b/vars/resolver_test.go @@ -0,0 +1,285 @@ +package vars + +import ( + "context" + "errors" + "testing" + + "github.com/xraph/ctrlplane/provider" + "github.com/xraph/ctrlplane/secrets" +) + +func derived() Scope { + return Scope{ + Instance: InstanceContext{ID: "inst_1", Name: "web"}, + Tenant: TenantContext{ID: "tnt_1"}, + Region: "us-east", + } +} + +func TestResolve_PlainStringDefaultAndOverride(t *testing.T) { + defs := []Definition{ + {Name: "image_tag", Type: TypeString, Default: "latest"}, + {Name: "log_level", Type: TypeString, Default: "info"}, + } + + scope, bindings, err := NewResolver().Resolve(context.Background(), defs, + map[string]any{"log_level": "debug"}, derived()) + if err != nil { + t.Fatalf("resolve: %v", err) + } + + if len(bindings) != 0 { + t.Fatalf("expected no bindings, got %d", len(bindings)) + } + + if scope.Var["image_tag"] != "latest" { + t.Errorf("image_tag = %v, want latest (default)", scope.Var["image_tag"]) + } + + if scope.Var["log_level"] != "debug" { + t.Errorf("log_level = %v, want debug (override)", scope.Var["log_level"]) + } +} + +func TestResolve_RequiredMissing(t *testing.T) { + defs := []Definition{{Name: "host", Type: TypeString, Required: true}} + + _, _, err := NewResolver().Resolve(context.Background(), defs, nil, derived()) + if !errors.Is(err, ErrMissingRequired) { + t.Fatalf("expected ErrMissingRequired, got %v", err) + } +} + +func TestResolve_OptionalUnsetOmitted(t *testing.T) { + defs := []Definition{{Name: "note", Type: TypeString}} + + scope, _, err := NewResolver().Resolve(context.Background(), defs, nil, derived()) + if err != nil { + t.Fatalf("resolve: %v", err) + } + + if _, ok := scope.Var["note"]; ok { + t.Errorf("optional unset variable should be omitted from scope, got %v", scope.Var["note"]) + } +} + +func TestValidateDefinition_Errors(t *testing.T) { + tests := []struct { + name string + def Definition + }{ + {"empty name", Definition{Type: TypeString}}, + {"default and expression", Definition{Name: "a", Type: TypeString, Default: "x", Expression: "{{ .region }}"}}, + {"enum without members", Definition{Name: "a", Type: TypeEnum}}, + {"secret without source", Definition{Name: "a", Type: TypeSecret}}, + {"secret computed", Definition{Name: "a", Type: TypeSecret, Secret: &provider.SecretRef{Key: "k"}, Expression: "{{ .region }}"}}, + {"unknown type", Definition{Name: "a", Type: Type("weird")}}, + {"bad pattern", Definition{Name: "a", Type: TypeString, Pattern: "([a-z"}}, + {"bad expression", Definition{Name: "a", Type: TypeString, Expression: "{{ .region "}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := NewResolver().Resolve(context.Background(), []Definition{tt.def}, nil, derived()) + if !errors.Is(err, ErrInvalidDefinition) { + t.Errorf("expected ErrInvalidDefinition, got %v", err) + } + }) + } +} + +func TestResolve_DuplicateName(t *testing.T) { + defs := []Definition{ + {Name: "x", Type: TypeString, Default: "a"}, + {Name: "x", Type: TypeString, Default: "b"}, + } + + _, _, err := NewResolver().Resolve(context.Background(), defs, nil, derived()) + if !errors.Is(err, ErrInvalidDefinition) { + t.Fatalf("expected ErrInvalidDefinition for duplicate, got %v", err) + } +} + +func TestResolve_IntCoercion(t *testing.T) { + tests := []struct { + name string + raw any + want int + }{ + {"native int", 5, 5}, + {"json float64", float64(7), 7}, + {"string", "9", 9}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defs := []Definition{{Name: "replicas", Type: TypeInt}} + + scope, _, err := NewResolver().Resolve(context.Background(), defs, + map[string]any{"replicas": tt.raw}, derived()) + if err != nil { + t.Fatalf("resolve: %v", err) + } + + if scope.Var["replicas"] != tt.want { + t.Errorf("replicas = %v (%T), want %d", scope.Var["replicas"], scope.Var["replicas"], tt.want) + } + }) + } +} + +func TestResolve_IntInvalid(t *testing.T) { + defs := []Definition{{Name: "replicas", Type: TypeInt}} + + _, _, err := NewResolver().Resolve(context.Background(), defs, + map[string]any{"replicas": "notanint"}, derived()) + if !errors.Is(err, ErrInvalidValue) { + t.Fatalf("expected ErrInvalidValue, got %v", err) + } +} + +func TestResolve_BoolCoercion(t *testing.T) { + defs := []Definition{{Name: "tls", Type: TypeBool}} + + scope, _, err := NewResolver().Resolve(context.Background(), defs, + map[string]any{"tls": "true"}, derived()) + if err != nil { + t.Fatalf("resolve: %v", err) + } + + if scope.Var["tls"] != true { + t.Errorf("tls = %v, want true", scope.Var["tls"]) + } +} + +func TestResolve_EnumValidAndInvalid(t *testing.T) { + defs := []Definition{{Name: "tier", Type: TypeEnum, Enum: []string{"free", "pro"}}} + + scope, _, err := NewResolver().Resolve(context.Background(), defs, + map[string]any{"tier": "pro"}, derived()) + if err != nil { + t.Fatalf("resolve valid: %v", err) + } + + if scope.Var["tier"] != "pro" { + t.Errorf("tier = %v, want pro", scope.Var["tier"]) + } + + _, _, err = NewResolver().Resolve(context.Background(), defs, + map[string]any{"tier": "enterprise"}, derived()) + if !errors.Is(err, ErrInvalidValue) { + t.Fatalf("expected ErrInvalidValue for bad enum, got %v", err) + } +} + +func TestResolve_PatternValidAndInvalid(t *testing.T) { + defs := []Definition{{Name: "slug", Type: TypeString, Pattern: "^[a-z]+$"}} + + if _, _, err := NewResolver().Resolve(context.Background(), defs, + map[string]any{"slug": "web"}, derived()); err != nil { + t.Fatalf("resolve valid: %v", err) + } + + _, _, err := NewResolver().Resolve(context.Background(), defs, + map[string]any{"slug": "Web1"}, derived()) + if !errors.Is(err, ErrInvalidValue) { + t.Fatalf("expected ErrInvalidValue for bad pattern, got %v", err) + } +} + +func TestResolve_SecretProducesBindingNotInScope(t *testing.T) { + defs := []Definition{ + {Name: "db-password", Type: TypeSecret, Secret: &provider.SecretRef{Key: "tenant/db/password", Type: secrets.SecretEnvVar}}, + {Name: "image_tag", Type: TypeString, Default: "latest"}, + } + + scope, bindings, err := NewResolver().Resolve(context.Background(), defs, nil, derived()) + if err != nil { + t.Fatalf("resolve: %v", err) + } + + if _, ok := scope.Var["db-password"]; ok { + t.Errorf("secret variable must NOT appear in scope.Var") + } + + if len(bindings) != 1 { + t.Fatalf("expected 1 binding, got %d", len(bindings)) + } + + b := bindings[0] + if b.VarName != "db-password" || b.EnvKey != "DB_PASSWORD" || b.Ref.Key != "tenant/db/password" { + t.Errorf("unexpected binding: %+v", b) + } +} + +func TestResolve_ComputedOverVarsAndContext(t *testing.T) { + defs := []Definition{ + {Name: "sub", Type: TypeString, Default: "web"}, + {Name: "host", Type: TypeString, Expression: "{{ .var.sub }}.{{ .region }}.example.com"}, + } + + scope, _, err := NewResolver().Resolve(context.Background(), defs, nil, derived()) + if err != nil { + t.Fatalf("resolve: %v", err) + } + + if scope.Var["host"] != "web.us-east.example.com" { + t.Errorf("host = %v, want web.us-east.example.com", scope.Var["host"]) + } +} + +func TestResolve_ComputedOrderingIndependentOfSlicePosition(t *testing.T) { + // "a" depends on "b" but is declared first; the fixpoint must still resolve. + defs := []Definition{ + {Name: "a", Type: TypeString, Expression: "{{ .var.b }}-a"}, + {Name: "b", Type: TypeString, Expression: "{{ .region }}-b"}, + } + + scope, _, err := NewResolver().Resolve(context.Background(), defs, nil, derived()) + if err != nil { + t.Fatalf("resolve: %v", err) + } + + if scope.Var["b"] != "us-east-b" { + t.Errorf("b = %v, want us-east-b", scope.Var["b"]) + } + + if scope.Var["a"] != "us-east-b-a" { + t.Errorf("a = %v, want us-east-b-a", scope.Var["a"]) + } +} + +func TestResolve_ComputedCycle(t *testing.T) { + defs := []Definition{ + {Name: "a", Type: TypeString, Expression: "{{ .var.b }}"}, + {Name: "b", Type: TypeString, Expression: "{{ .var.a }}"}, + } + + _, _, err := NewResolver().Resolve(context.Background(), defs, nil, derived()) + if !errors.Is(err, ErrCycle) { + t.Fatalf("expected ErrCycle, got %v", err) + } +} + +func TestValidateDefinitions(t *testing.T) { + if err := ValidateDefinitions([]Definition{ + {Name: "a", Type: TypeString, Default: "x"}, + {Name: "b", Type: TypeInt}, + }); err != nil { + t.Errorf("valid definitions rejected: %v", err) + } + + if err := ValidateDefinitions([]Definition{ + {Name: "a", Type: TypeEnum}, + }); !errors.Is(err, ErrInvalidDefinition) { + t.Errorf("expected ErrInvalidDefinition for enum without members, got %v", err) + } + + if err := ValidateDefinitions([]Definition{ + {Name: "a", Type: TypeString}, + {Name: "a", Type: TypeString}, + }); !errors.Is(err, ErrInvalidDefinition) { + t.Errorf("expected ErrInvalidDefinition for duplicate, got %v", err) + } +} diff --git a/vars/types.go b/vars/types.go new file mode 100644 index 0000000..4650f91 --- /dev/null +++ b/vars/types.go @@ -0,0 +1,78 @@ +package vars + +import "github.com/xraph/ctrlplane/provider" + +// Type enumerates the variable value kinds. +type Type string + +const ( + // TypeString is a free-form string variable. + TypeString Type = "string" + + // TypeInt is an integer variable. + TypeInt Type = "int" + + // TypeBool is a boolean variable. + TypeBool Type = "bool" + + // TypeEnum is a string variable constrained to its Enum members. + TypeEnum Type = "enum" + + // TypeSecret is a secret reference resolved to a binding, never inlined. + TypeSecret Type = "secret" +) + +// Definition declares a single template variable. It is serialized into a +// template's variables JSONB column, so only JSON tags are needed. +type Definition struct { + Name string `json:"name" validate:"required"` + Description string `json:"description,omitempty"` + Type Type `json:"type" validate:"required"` + Required bool `json:"required,omitempty"` + Default any `json:"default,omitempty"` + Enum []string `json:"enum,omitempty"` + Secret *provider.SecretRef `json:"secret,omitempty"` + Expression string `json:"expression,omitempty"` + Pattern string `json:"pattern,omitempty"` +} + +// InstanceContext is the per-instance derived context exposed to templates +// as {{ .instance.id }} and {{ .instance.name }}. +type InstanceContext struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// TenantContext is the tenant-scoped derived context exposed as +// {{ .tenant.id }}. +type TenantContext struct { + ID string `json:"id"` +} + +// Scope is the resolved variable context handed to the renderer. Var holds +// every resolved plain and computed variable; secret variables are excluded +// (they are returned as bindings instead). +type Scope struct { + Var map[string]any + Instance InstanceContext + Tenant TenantContext + Region string +} + +// Root builds the case-sensitive template root so expressions use lowercase +// selectors: {{ .var.x }}, {{ .instance.id }}, {{ .tenant.id }}, {{ .region }}. +// It is exported so the render package can evaluate templates against the +// same scope shape the resolver uses for computed expressions. +func (s Scope) Root() map[string]any { + return map[string]any{ + "var": s.Var, + "instance": map[string]any{ + "id": s.Instance.ID, + "name": s.Instance.Name, + }, + "tenant": map[string]any{ + "id": s.Tenant.ID, + }, + "region": s.Region, + } +}