diff --git a/api/v1alpha1/casting.schema.json b/api/v1alpha1/casting.schema.json index 56078887..6e9ecddf 100644 --- a/api/v1alpha1/casting.schema.json +++ b/api/v1alpha1/casting.schema.json @@ -7,6 +7,9 @@ }, { "$ref": "collectionagent/casting.schema.json" + }, + { + "$ref": "infrastructure/casting.schema.json" } ] } \ No newline at end of file diff --git a/api/v1alpha1/casting_kind.go b/api/v1alpha1/casting_kind.go index 3d710907..f0002878 100644 --- a/api/v1alpha1/casting_kind.go +++ b/api/v1alpha1/casting_kind.go @@ -19,6 +19,7 @@ var _ jsonschema.Enum = (*Kind)(nil) var ( KindInstallation Kind = Kind{s: "Installation"} KindCollectionAgent Kind = Kind{s: "CollectionAgent"} + KindInfrastructure Kind = Kind{s: "Infrastructure"} ) // Kind discriminates between top-level casting resource types. @@ -33,7 +34,7 @@ func (kind Kind) String() string { } func Kinds() []Kind { - return []Kind{KindInstallation, KindCollectionAgent} + return []Kind{KindInstallation, KindCollectionAgent, KindInfrastructure} } func (kind Kind) MarshalJSON() ([]byte, error) { diff --git a/api/v1alpha1/infrastructure/casting.go b/api/v1alpha1/infrastructure/casting.go new file mode 100644 index 00000000..528f9fd0 --- /dev/null +++ b/api/v1alpha1/infrastructure/casting.go @@ -0,0 +1,68 @@ +package infrastructure + +import ( + "github.com/signoz/foundry/api/v1alpha1" + "github.com/signoz/foundry/internal/domain" +) + +// Casting is the Infrastructure kind. +type Casting struct { + v1alpha1.CastingMeta `json:",inline" yaml:",inline"` + Spec Spec `json:"spec" yaml:"spec" required:"true" description:"Infrastructure specification"` + _ struct{} `additionalProperties:"false"` +} + +// Spec is the Infrastructure-specific configuration. +type Spec struct { + Deployment v1alpha1.TypeDeployment `json:"deployment" yaml:"deployment" required:"true" description:"Deployment configuration for the platform"` + Resource Resource `json:"resource" yaml:"resource" required:"true" description:"The configuration for the resource molding"` + Patches []v1alpha1.PatchEntry `json:"patches,omitempty" yaml:"patches,omitempty" description:"Patch operations to apply to generated materials"` + _ struct{} `additionalProperties:"false"` +} + +var _ v1alpha1.Machinery = (*Casting)(nil) + +func Default() *Casting { + return &Casting{ + CastingMeta: v1alpha1.CastingMeta{ + TypeVersion: v1alpha1.TypeVersion{APIVersion: "v1alpha1"}, + Kind: v1alpha1.KindInfrastructure, + Metadata: v1alpha1.TypeMetadata{Name: "signoz"}, + }, + Spec: Spec{}, + } +} + +// Example returns a minimal Infrastructure; the forge pipeline fills in +// defaults. +func Example() *Casting { + return &Casting{ + CastingMeta: v1alpha1.CastingMeta{ + TypeVersion: v1alpha1.TypeVersion{APIVersion: "v1alpha1"}, + Kind: v1alpha1.KindInfrastructure, + Metadata: v1alpha1.TypeMetadata{Name: "signoz"}, + }, + } +} + +// Kind reports the casting kind. Shadows the embedded CastingMeta.Kind field; +// the field stays reachable as c.CastingMeta.Kind. +func (c *Casting) Kind() v1alpha1.Kind { + return v1alpha1.KindInfrastructure +} + +// MergeStatusIntoSpec folds molding-written status into spec. A casting reads +// the settled document from the resource's status directly, so nothing folds. +func (c *Casting) MergeStatusIntoSpec() error { + return nil +} + +// TrackableProperties returns analytics tags for the casting. +func (c *Casting) TrackableProperties() domain.Properties { + return domain.NewProperties(). + Set("kind", v1alpha1.KindInfrastructure.String()). + Set("platform", c.Spec.Deployment.Platform.String()). + Set("mode", c.Spec.Deployment.Mode.String()). + Set("flavor", c.Spec.Deployment.Flavor.String()). + Set("patches_count", len(c.Spec.Patches)) +} diff --git a/api/v1alpha1/infrastructure/casting.schema.json b/api/v1alpha1/infrastructure/casting.schema.json new file mode 100644 index 00000000..197e60f0 --- /dev/null +++ b/api/v1alpha1/infrastructure/casting.schema.json @@ -0,0 +1,371 @@ +{ + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "additionalProperties": false, + "definitions": { + "InfrastructureResource": { + "additionalProperties": false, + "properties": { + "spec": { + "$ref": "#/definitions/V1Alpha1MoldingSpec" + }, + "status": { + "$ref": "#/definitions/InfrastructureResourceStatus", + "description": "Status of the resource" + } + }, + "type": "object" + }, + "InfrastructureResourceStatus": { + "additionalProperties": false, + "properties": { + "config": { + "$ref": "#/definitions/V1Alpha1TypeConfig", + "description": "Configuration for the molding" + }, + "env": { + "description": "Environment variables for the molding", + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "extras": { + "description": "Extra information about the molding", + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + }, + "type": "object" + }, + "InfrastructureSpec": { + "required": [ + "deployment", + "resource" + ], + "additionalProperties": false, + "properties": { + "deployment": { + "$ref": "#/definitions/V1Alpha1TypeDeployment", + "description": "Deployment configuration for the platform" + }, + "patches": { + "description": "Patch operations to apply to generated materials", + "items": { + "$ref": "#/definitions/V1Alpha1PatchEntry" + }, + "type": "array" + }, + "resource": { + "$ref": "#/definitions/InfrastructureResource", + "description": "The configuration for the resource molding" + } + }, + "type": "object" + }, + "V1Alpha1Flavor": { + "enum": [ + "compose", + "swarm", + "binary", + "kustomize", + "helm", + "blueprint", + "stack", + "template", + "terraform" + ], + "type": "string" + }, + "V1Alpha1Kind": { + "enum": [ + "Infrastructure" + ], + "type": "string" + }, + "V1Alpha1Mode": { + "enum": [ + "docker", + "systemd", + "kubernetes", + "ec2" + ], + "type": "string" + }, + "V1Alpha1MoldingSpec": { + "additionalProperties": false, + "properties": { + "cluster": { + "$ref": "#/definitions/V1Alpha1TypeCluster", + "description": "Cluster configuration for the molding" + }, + "config": { + "$ref": "#/definitions/V1Alpha1TypeConfig", + "description": "Configuration for the molding" + }, + "enabled": { + "description": "Whether the molding is enabled", + "default": true, + "type": [ + "null", + "boolean" + ] + }, + "env": { + "description": "Environment variables for the molding", + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "image": { + "description": "Container image of the molding", + "examples": [ + "signoz/signoz:latest" + ], + "pattern": "^[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$", + "type": "string" + }, + "version": { + "description": "The version of the molding to use", + "examples": [ + "latest" + ], + "type": "string" + } + }, + "type": "object" + }, + "V1Alpha1PatchEntry": { + "required": [ + "target", + "operations" + ], + "additionalProperties": false, + "properties": { + "operations": { + "description": "JSON Patch (RFC 6902) operations to apply. Used by the jsonpatch driver.", + "items": { + "$ref": "#/definitions/V1Alpha1PatchOperation" + }, + "minItems": 1, + "type": [ + "array", + "null" + ] + }, + "target": { + "description": "Target output file to patch", + "examples": [ + "compose.yaml", + "signoz/deployment.yaml", + "values.yaml", + "telemetrystore/telemtrystore-clickhouse-0-*.yaml" + ], + "minLength": 1, + "type": "string" + }, + "type": { + "description": "Patch driver type. Defaults to jsonpatch.", + "default": "jsonpatch", + "examples": [ + "jsonpatch" + ], + "enum": [ + "", + "jsonpatch" + ], + "type": "string" + } + }, + "type": "object" + }, + "V1Alpha1PatchOperation": { + "required": [ + "op", + "path" + ], + "additionalProperties": false, + "properties": { + "from": { + "description": "Source JSON Pointer for move and copy operations", + "examples": [ + "/services/clickhouse/old_field" + ], + "pattern": "^/", + "type": "string" + }, + "op": { + "description": "JSON Patch (RFC 6902) operation type", + "enum": [ + "add", + "remove", + "replace", + "move", + "copy", + "test" + ], + "type": "string" + }, + "path": { + "description": "JSON Pointer (RFC 6901) to the target location", + "examples": [ + "/services/clickhouse/mem_limit" + ], + "pattern": "^/", + "type": "string" + }, + "value": { + "description": "Value for add, replace, or test operations" + } + }, + "type": "object" + }, + "V1Alpha1Platform": { + "enum": [ + "render", + "coolify", + "railway", + "ecs", + "aws", + "gcp", + "azure" + ], + "type": "string" + }, + "V1Alpha1Status": { + "additionalProperties": false, + "properties": { + "checksum": { + "description": "Checksum of the casting file", + "type": "string" + } + }, + "type": "object" + }, + "V1Alpha1TypeCluster": { + "additionalProperties": false, + "properties": { + "replicas": { + "description": "Number of replicas for the molding.", + "examples": [ + 1 + ], + "minimum": 0, + "type": [ + "null", + "integer" + ] + }, + "shards": { + "description": "Number of shards for the molding", + "examples": [ + 1 + ], + "minimum": 1, + "type": [ + "null", + "integer" + ] + } + }, + "type": "object" + }, + "V1Alpha1TypeConfig": { + "additionalProperties": false, + "properties": { + "data": { + "description": "Configuration data as key-value pairs.", + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + }, + "type": "object" + }, + "V1Alpha1TypeDeployment": { + "additionalProperties": false, + "properties": { + "flavor": { + "$ref": "#/definitions/V1Alpha1Flavor", + "description": "Flavor of mode for the deployment" + }, + "mode": { + "$ref": "#/definitions/V1Alpha1Mode", + "description": "Type of installation method" + }, + "platform": { + "$ref": "#/definitions/V1Alpha1Platform", + "description": "Provider where an installation runs on" + } + }, + "type": "object" + }, + "V1Alpha1TypeMetadata": { + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "annotations": { + "description": "Annotations is an unstructured key-value map for arbitrary metadata. Can be used to specify deployment-specific settings.", + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, + "name": { + "description": "The name of this installation. This name is used to identify the installation.", + "default": "signoz", + "examples": [ + "signoz" + ], + "maxLength": 63, + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", + "type": "string" + } + }, + "type": "object" + } + }, + "properties": { + "apiVersion": { + "description": "API Version of the configuration schema.", + "default": "v1alpha1", + "examples": [ + "v1alpha1" + ], + "enum": [ + "v1alpha1" + ], + "type": "string" + }, + "kind": { + "$ref": "#/definitions/V1Alpha1Kind", + "description": "Kind of the casting resource." + }, + "metadata": { + "$ref": "#/definitions/V1Alpha1TypeMetadata", + "description": "Metadata of the casting configuration" + }, + "spec": { + "$ref": "#/definitions/InfrastructureSpec", + "description": "Infrastructure specification" + }, + "status": { + "$ref": "#/definitions/V1Alpha1Status", + "description": "Status of the casting" + } + }, + "type": "object" +} \ No newline at end of file diff --git a/api/v1alpha1/infrastructure/resource.go b/api/v1alpha1/infrastructure/resource.go new file mode 100644 index 00000000..176b958d --- /dev/null +++ b/api/v1alpha1/infrastructure/resource.go @@ -0,0 +1,20 @@ +package infrastructure + +import "github.com/signoz/foundry/api/v1alpha1" + +// Resource is the resource molding's slot on an Infrastructure casting: what a +// substrate must provide. +type Resource struct { + Spec v1alpha1.MoldingSpec `json:"spec" yaml:"spec" jsonschema:"description=Specification for the resource"` + + Status ResourceStatus `json:"status" yaml:"status,omitempty" description:"Status of the resource"` + + _ struct{} `additionalProperties:"false"` +} + +// ResourceStatus carries the settled requirement document. +type ResourceStatus struct { + v1alpha1.MoldingStatus `json:",inline" yaml:",inline"` + + _ struct{} `additionalProperties:"false"` +} diff --git a/api/v1alpha1/infrastructure/resource_config.go b/api/v1alpha1/infrastructure/resource_config.go new file mode 100644 index 00000000..6f04e789 --- /dev/null +++ b/api/v1alpha1/infrastructure/resource_config.go @@ -0,0 +1,86 @@ +package infrastructure + +// ResourceConfig is the requirement document, written as resource.yaml: a +// molding baseline, a casting's contribution, then the operator's spec, which +// wins. +type ResourceConfig struct { + Networking ResourceConfigNetworking `json:"networking,omitzero" description:"The network the substrate runs in"` + + IAM ResourceConfigIAM `json:"iam,omitzero" description:"Identity the substrate's workloads assume"` + + CloudLabels map[string]string `json:"cloudLabels,omitempty" description:"Tags applied to every resource the substrate provisions"` + + InstanceGroups map[string]ResourceConfigInstanceGroup `json:"instanceGroups,omitempty" description:"Pools of nodes the resource requires, keyed by a reference of your choosing"` + + _ struct{} `additionalProperties:"false"` +} + +// ResourceConfigNetworking follows kOps' NetworkingSpec. +type ResourceConfigNetworking struct { + // A network is adopted whole: every subnet then states its own id. + NetworkID string `json:"networkID,omitempty" description:"Provider ID of an existing network to adopt; empty creates one" example:"vpc-0a1b2c3d"` + + NetworkCIDR string `json:"networkCIDR,omitempty" description:"CIDR block for the network" example:"10.0.0.0/16"` + + Subnets map[string]ResourceConfigSubnet `json:"subnets,omitempty" description:"Subnets carved out of the network, keyed by a reference of your choosing"` + + _ struct{} `additionalProperties:"false"` +} + +// ResourceConfigSubnet follows kOps' ClusterSubnetSpec. Zone has no default: +// letters are not contiguous within a region and the mapping is per-account. +type ResourceConfigSubnet struct { + Type string `json:"type,omitempty" description:"Whether the subnet routes to an internet gateway: private or public"` + + Zone string `json:"zone,omitempty" description:"Availability zone the subnet lives in" example:"us-east-1a"` + + CIDR string `json:"cidr,omitempty" description:"CIDR block for the subnet, carved out of the network" example:"10.0.0.0/19"` + + // Private subnets only; empty creates a gateway in a public subnet of the + // same zone. + Egress string `json:"egress,omitempty" description:"Provider ID of an existing NAT gateway this private subnet routes through; empty creates one" example:"nat-0a1b2c3d"` + + ID string `json:"id,omitempty" description:"Provider ID of an existing subnet to adopt; empty creates one" example:"subnet-0a1b2c3d"` + + _ struct{} `additionalProperties:"false"` +} + +// ResourceConfigIAM constrains the roles the substrate creates; which roles +// exist is the platform's, and their names are derived. +type ResourceConfigIAM struct { + PermissionsBoundary string `json:"permissionsBoundary,omitempty" description:"Policy ARN attached as the permissions boundary of every role the substrate creates"` + + _ struct{} `additionalProperties:"false"` +} + +// ResourceConfigInstanceGroup follows kOps' InstanceGroupSpec, narrowed to what +// foundry has to understand. +type ResourceConfigInstanceGroup struct { + Storage string `json:"storage,omitempty" description:"Durability of the group's storage, persistent or ephemeral, and the only fact about it a consuming casting can select on"` + + MachineType string `json:"machineType,omitempty" description:"Provider machine type for each node in the group" example:"m5.large"` + + // MinSize and MaxSize are equal on a pinned group. + MinSize *int `json:"minSize,omitempty" minimum:"0" description:"Minimum number of nodes in the group"` + + MaxSize *int `json:"maxSize,omitempty" minimum:"0" description:"Maximum number of nodes in the group"` + + // References into networking.subnets; a pinned group's nodes are laid out + // across them in order. + Subnets []string `json:"subnets,omitempty" description:"Subnet references the group's nodes are placed in"` + + RootVolume ResourceConfigVolume `json:"rootVolume,omitzero" description:"The disk each node boots from, which dies with it"` + + DataVolume *ResourceConfigVolume `json:"dataVolume,omitempty" description:"Volume attached to each node that outlives it; persistent storage class only"` + + _ struct{} `additionalProperties:"false"` +} + +// ResourceConfigVolume follows kOps' VolumeSpec. +type ResourceConfigVolume struct { + Size *int `json:"size,omitempty" minimum:"1" description:"Size of the volume in GB"` + + Type string `json:"type,omitempty" description:"Provider volume type" example:"gp3"` + + _ struct{} `additionalProperties:"false"` +} diff --git a/api/v1alpha1/infrastructure/schema.go b/api/v1alpha1/infrastructure/schema.go new file mode 100644 index 00000000..44dfafe3 --- /dev/null +++ b/api/v1alpha1/infrastructure/schema.go @@ -0,0 +1,18 @@ +package infrastructure + +import ( + _ "embed" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/signoz/foundry/api/v1alpha1" +) + +//go:embed casting.schema.json +var schemaJSON []byte + +var schema = v1alpha1.MustResolveSchema(schemaJSON) + +// Schema returns the resolved JSON schema for an Infrastructure casting. +func Schema() *jsonschema.Resolved { + return schema +} diff --git a/api/v1alpha1/infrastructure/schema_test.go b/api/v1alpha1/infrastructure/schema_test.go new file mode 100644 index 00000000..fea071a4 --- /dev/null +++ b/api/v1alpha1/infrastructure/schema_test.go @@ -0,0 +1,66 @@ +package infrastructure + +import ( + "encoding/json" + "testing" + + "github.com/signoz/foundry/api/v1alpha1" + + "github.com/stretchr/testify/assert" +) + +func TestSchema(t *testing.T) { + assert.NotNil(t, Schema()) +} + +func TestSchemaValidate(t *testing.T) { + tests := []struct { + name string + mutate func(casting *Casting) + pass bool + }{ + { + name: "Deployment_Valid", + mutate: func(casting *Casting) { + casting.Spec.Deployment = v1alpha1.TypeDeployment{ + Platform: v1alpha1.PlatformECS, + Mode: v1alpha1.ModeEC2, + Flavor: v1alpha1.FlavorTerraform, + } + }, + pass: true, + }, + { + name: "NameMissing_Invalid", + mutate: func(casting *Casting) { + casting.Spec.Deployment = v1alpha1.TypeDeployment{ + Platform: v1alpha1.PlatformECS, + Mode: v1alpha1.ModeEC2, + Flavor: v1alpha1.FlavorTerraform, + } + casting.Metadata.Name = "" + }, + pass: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + casting := Default() + tt.mutate(casting) + + contents, err := json.Marshal(casting) + assert.NoError(t, err) + + payload := map[string]any{} + assert.NoError(t, json.Unmarshal(contents, &payload)) + + err = Schema().Validate(payload) + if !tt.pass { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} diff --git a/api/v1alpha1/installation/casting.go b/api/v1alpha1/installation/casting.go index fe7f3f5f..103132ad 100644 --- a/api/v1alpha1/installation/casting.go +++ b/api/v1alpha1/installation/casting.go @@ -40,7 +40,6 @@ func Default(declared *Casting) *Casting { Metadata: v1alpha1.TypeMetadata{Name: "signoz"}, }, Spec: Spec{ - Infrastructure: DefaultInfrastructure(), Signoz: DefaultSigNoz(), TelemetryStore: DefaultTelemetryStore(), TelemetryKeeper: DefaultTelemetryKeeper(declared.Spec.TelemetryKeeper.Kind), @@ -99,7 +98,7 @@ func (c *Casting) TrackableProperties() domain.Properties { Set("mode", c.Spec.Deployment.Mode.String()). Set("flavor", c.Spec.Deployment.Flavor.String()). Set("patches_count", len(c.Spec.Patches)). - Set("infrastructure_enabled", c.Spec.Infrastructure.Enabled). + Set("infrastructure_bound", c.Spec.Infrastructure.Name != ""). Set("metastore_kind", c.Spec.MetaStore.Kind.String()). Set("telemetrystore_kind", c.Spec.TelemetryStore.Kind.String()). Set("telemetrykeeper_kind", c.Spec.TelemetryKeeper.Kind.String()). diff --git a/api/v1alpha1/installation/casting.schema.json b/api/v1alpha1/installation/casting.schema.json index 01a00b04..c41aa1f1 100644 --- a/api/v1alpha1/installation/casting.schema.json +++ b/api/v1alpha1/installation/casting.schema.json @@ -10,14 +10,11 @@ "InstallationInfrastructure": { "additionalProperties": false, "properties": { - "enabled": { - "type": "boolean" - }, - "status": { - "additionalProperties": { - "type": "string" - }, - "type": "object" + "name": { + "description": "Name of the infrastructure casting this installation runs on", + "maxLength": 63, + "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", + "type": "string" } }, "type": "object" diff --git a/api/v1alpha1/installation/infrastructure.go b/api/v1alpha1/installation/infrastructure.go index 7baa07a8..918b2585 100644 --- a/api/v1alpha1/installation/infrastructure.go +++ b/api/v1alpha1/installation/infrastructure.go @@ -1,36 +1,11 @@ package installation -import "encoding/json" - -// Infrastructure holds the configuration for infrastructure manifest generation (e.g., Terraform). -// The cloud provider is resolved automatically from spec.deployment.platform — no provider field -// is needed here. +// Infrastructure is the installation's binding to the substrate it runs on. The +// consumer owns the binding because the two castings share no state: naming the +// substrate is what lets a casting derive the tag filter that finds its +// resources. Only a casting resolves it. type Infrastructure struct { - // Whether infrastructure manifest generation is enabled - Enabled bool `json:"enabled" yaml:"enabled"` - - // Status holds the generated IaC file contents keyed by filename (e.g. "main.tf.json"). - // This is populated by foundry after generation and written to the lock file. - Status map[string]string `json:"status,omitempty" yaml:"status,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty" pattern:"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" maxLength:"63" description:"Name of the infrastructure casting this installation runs on"` _ struct{} `additionalProperties:"false"` } - -// MarshalJSON implements json.Marshaler. It manually omits Status when zero -// so that the strategic merge patch doesn't overwrite defaults with empty values. -func (i Infrastructure) MarshalJSON() ([]byte, error) { - m := map[string]any{ - "enabled": i.Enabled, - } - if len(i.Status) > 0 { - m["status"] = i.Status - } - return json.Marshal(m) -} - -// DefaultInfrastructure returns the default Infrastructure configuration. -func DefaultInfrastructure() Infrastructure { - return Infrastructure{ - Enabled: false, - } -} diff --git a/api/v1alpha1/molding_kind.go b/api/v1alpha1/molding_kind.go index 488848a8..a6de92af 100644 --- a/api/v1alpha1/molding_kind.go +++ b/api/v1alpha1/molding_kind.go @@ -19,6 +19,7 @@ var ( MoldingKindSignoz MoldingKind = MoldingKind{s: "signoz"} MoldingKindCollector MoldingKind = MoldingKind{s: "collector"} MoldingKindMCP MoldingKind = MoldingKind{s: "mcp"} + MoldingKindResource MoldingKind = MoldingKind{s: "resource"} ) type MoldingKind struct { diff --git a/cmd/foundryctl/gen.go b/cmd/foundryctl/gen.go index 839bfe3e..3aca084c 100644 --- a/cmd/foundryctl/gen.go +++ b/cmd/foundryctl/gen.go @@ -11,6 +11,7 @@ import ( "github.com/signoz/foundry/api/v1alpha1" "github.com/signoz/foundry/api/v1alpha1/collectionagent" + "github.com/signoz/foundry/api/v1alpha1/infrastructure" "github.com/signoz/foundry/api/v1alpha1/installation" installationcasting "github.com/signoz/foundry/internal/casting/installation" "github.com/signoz/foundry/internal/domain" @@ -30,6 +31,7 @@ type schemaTarget struct { var schemaTargets = []schemaTarget{ {v1alpha1.KindInstallation, installation.Casting{}}, {v1alpha1.KindCollectionAgent, collectionagent.Casting{}}, + {v1alpha1.KindInfrastructure, infrastructure.Casting{}}, } func registerGenCmd(rootCmd *cobra.Command) { diff --git a/docs/concepts/infrastructure.md b/docs/concepts/infrastructure.md new file mode 100644 index 00000000..c9e5415c --- /dev/null +++ b/docs/concepts/infrastructure.md @@ -0,0 +1,269 @@ +# Infrastructure + +An Infrastructure casting provisions what SigNoz runs on: the network, the machines, and the disks. An Installation casting deploys SigNoz onto it. + +The two are separate castings, forged and applied separately. Apply Infrastructure first. + +## The casting + +```yaml +apiVersion: v1alpha1 +kind: Infrastructure +metadata: + name: signoz +spec: + deployment: + platform: ecs + mode: ec2 + flavor: terraform +``` + +| Field | Meaning | +|---|---| +| `metadata.name` | Names everything provisioned. Up to 63 characters, lowercase alphanumeric with interior hyphens | +| `spec.deployment` | Which casting provisions. Each `platform`, `mode` and `flavor` combination has its own | +| `spec.resource` | What to provision. See [The document](#the-document) | +| `spec.patches` | RFC 6902 patches applied to the generated files | + +Forging writes the generated files to `pours/infrastructure/` and the resolved casting to `casting.yaml.lock`. + +## The document + +`resource.yaml` says what to provision. Foundry starts from a default sized for a standard SigNoz installation, the casting fills in what the platform decides, and anything you put in `spec.resource.spec.config.data` wins. The field names follow [kOps](https://kops.sigs.k8s.io/). + +```yaml +networking: + networkCIDR: 10.0.0.0/16 + subnets: + private-a: + type: private + zone: us-east-1a + cidr: 10.0.0.0/19 + public-a: + type: public + zone: us-east-1a + cidr: 10.0.96.0/22 +instanceGroups: + persistent: + storage: persistent + machineType: m5.large + minSize: 3 + maxSize: 3 + rootVolume: + size: 30 + type: gp3 + dataVolume: + size: 50 + type: gp3 + ephemeral: + storage: ephemeral + machineType: c5.large + minSize: 1 + maxSize: 1 + rootVolume: + size: 30 + type: gp3 +``` + +Zones, machine types and volume types are the provider's own words, passed through as written. + +### Networking + +| Field | Meaning | +|---|---| +| `networkCIDR` | The block every subnet is carved out of | +| `networkID` | ID of an existing network to use. Empty creates one. See [Adopting](#adopting-what-you-already-run) | +| `subnets` | Subnets, keyed by a name you choose | + +The subnet's key names its resources and is what an instance group points at. `private-a` becomes `signoz-sub-private-a`. + +| Subnet field | Meaning | +|---|---| +| `type` | `private` or `public`. Workloads go in private subnets | +| `zone` | The provider's availability zone, as written | +| `cidr` | The block carved out of `networkCIDR` | +| `egress` | ID of a gateway this private subnet already routes out through. Empty creates one | +| `id` | ID of an existing subnet to use. Empty creates one | + +Forging fails unless: + +- Every subnet states a `zone`. There is no default. +- There is at least one subnet, and at least one of them is private. +- Every private subnet without an `egress` has a public subnet in the same zone. +- Every subnet states its own `id` when `networkID` is set. + +### Instance groups + +| Field | Meaning | +|---|---| +| `storage` | `persistent` or `ephemeral`. See below | +| `machineType` | Provider machine type for each node | +| `minSize` | Smallest the group may be | +| `maxSize` | Largest the group may grow to | +| `subnets` | Subnet keys to place nodes in. Empty means every private subnet | +| `rootVolume` | Boot disk per node: `size` in GB, and `type` | +| `dataVolume` | Disk that outlives the node. Persistent groups only | + +Nodes are laid out across the group's subnets in order, and a node's data volume goes wherever the node does. + +### Storage classes + +| Class | Data | Size | Used by | +|---|---|---|---| +| `persistent` | Each node carries a disk that outlives it | Fixed: `minSize` and `maxSize` must match | ClickHouse, Keeper, PostgreSQL | +| `ephemeral` | Keeps nothing | Scales between the bounds | Collector, MCP, UI | + +The class is the only thing about a group an Installation can select on. Two groups may share a class, and the Installation reaches both. + +### Everything else + +| Field | Meaning | +|---|---| +| `iam.permissionsBoundary` | Policy ARN attached as the permissions boundary of every role created | +| `cloudLabels` | Your own tags, added to every resource provisioned. They cannot rename a tag an Installation matches on | + +### Changing the defaults + +State only what you are changing. To drop the persistent group entirely, set `persistent: null`. + +```yaml +apiVersion: v1alpha1 +kind: Infrastructure +metadata: + name: signoz +spec: + deployment: + platform: ecs + mode: ec2 + flavor: terraform + resource: + spec: + config: + data: + resource.yaml: | + networking: + subnets: + private-a: + type: private + zone: us-east-1a + cidr: 10.0.0.0/19 + private-b: + type: private + zone: us-east-1b + cidr: 10.0.32.0/19 + public-a: + type: public + zone: us-east-1a + cidr: 10.0.96.0/22 + public-b: + type: public + zone: us-east-1b + cidr: 10.0.100.0/22 + instanceGroups: + persistent: + minSize: 6 + maxSize: 6 + machineType: m5.xlarge + ephemeral: + minSize: 2 + maxSize: 4 +``` + +**If you scale SigNoz, scale the persistent group yourself.** Three persistent nodes cover one Keeper, the metadata store, and one ClickHouse node. + +## Names + +``` +-[-...] +``` + +Everything provisioned starts with the casting's `metadata.name`, then a short word for what it is, then whatever tells it apart from its siblings: the subnet or group key, a node's position in its group, or what a rule admits. + +A subnet keyed `private-a` becomes `signoz-sub-private-a`. The first node of a group keyed `persistent` becomes `signoz-node-persistent-0`. + +## Tags + +Every resource is tagged. Tags live under `foundry.signoz.io/` and are one segment deep. + +| Tag | Value | Read by | +|---|---|---| +| `foundry.signoz.io/name` | The casting's `metadata.name` | An Installation, to find these resources | +| `foundry.signoz.io/subnet-type` | `private` or `public` | An Installation, to pick subnets for a workload | +| `foundry.signoz.io/storage` | `persistent` or `ephemeral` | An Installation, to pick nodes for a component | +| `foundry.signoz.io/identities` | Which components own a disk | An Installation, to keep a component on its own data | +| `foundry.signoz.io/owner` | `owned` or `shared` | People, to tell what Foundry may delete | +| `foundry.signoz.io/managed-by` | `foundry` | People | +| `foundry.signoz.io/kind` | The casting Kind that tagged it | People | +| `Name` | The name above | Cloud consoles, which show it as a display name | + +The first four are what an Installation searches on. The rest describe a resource. + +Where a provider rejects a dot or a slash in a tag key, it is rendered in whatever that provider accepts. + +## Binding an Installation + +An Installation names the infrastructure it runs on: + +```yaml +spec: + infrastructure: + name: signoz +``` + +Everything else follows from that name: + +| What the Installation needs | How it finds it | +|---|---| +| Something Foundry named | Builds the same name again | +| Which subnets to place a workload in | `foundry.signoz.io/name` and `foundry.signoz.io/subnet-type` | +| Which machines a component runs on | `foundry.signoz.io/name` and `foundry.signoz.io/storage` | +| Which component owns a disk | `foundry.signoz.io/identities` on the disk | + +Each one becomes a variable in the generated files, defaulted to what was worked out. For infrastructure Foundry did not provision, set the variable instead and nothing is looked up. + +A search that matches nothing fails the plan. + +## Disks + +A persistent node's disk outlives the machine it is attached to, so a component keeps its data when its machine is replaced. The disk is tagged with the components that own it, such as `telemetrystore-0-0`, and the component is placed on whichever machine currently holds it. + +| Change | What happens | +|---|---| +| Add a ClickHouse replica | It takes a free disk, and runs on the machine holding it | +| Resize a machine | The machine is replaced, its disk moves to the new one, and the component follows | +| Change a disk's size | The disk is grown in place | +| Remove a replica | Its disk keeps the tag until something else takes it | +| Destroy a disk | The data is gone, and the replica starts empty on another disk | + +## Adopting what you already run + +Set `networking.networkID` to a network you already run, and give every subnet its own `id`: + +```yaml +networking: + networkID: + subnets: + private-a: + type: private + zone: us-east-1a + id: +``` + +Foundry then creates none of the networking, tags nothing it did not create, and provisions only the machines and disks inside. + +A private subnet that already has a way out can keep it without adopting the whole network: set `egress` to the gateway it routes through. + +Persistent components still need an Infrastructure casting. Their disks are found by tag. + +## Where things live + +| Package | What it holds | +|---|---| +| `api/v1alpha1/infrastructure` | `Casting`, `Spec`, `Resource` and `ResourceConfig`, plus the generated schema | +| `internal/config/yamlconfig` | Loads each document in a file by its `kind` | +| `internal/molding/infrastructure` | The `Molding` and `MoldingEnricher` contracts for this kind | +| `internal/molding/infrastructure/resourcemolding` | Settles and validates `resource.yaml` | +| `internal/casting/infrastructure` | The `Casting` contract, the planner, and the registry | +| `internal/contract` | Substrate, keys, identities, selections, tag keys, storage classes, subnet types | +| `internal/contract/aws` | The descriptor, and the AWS name and tag grammar | +| `internal/contract/aws/ecs`, `internal/contract/aws/eks` | `Derive` per mode | diff --git a/internal/casting/infrastructure/casting.go b/internal/casting/infrastructure/casting.go new file mode 100644 index 00000000..fdbf1de2 --- /dev/null +++ b/internal/casting/infrastructure/casting.go @@ -0,0 +1,15 @@ +package infrastructure + +import ( + "context" + + "github.com/signoz/foundry/api/v1alpha1/infrastructure" + infrastructuremolding "github.com/signoz/foundry/internal/molding/infrastructure" + "github.com/signoz/foundry/internal/pourer" +) + +type Casting interface { + Enricher(ctx context.Context, config *infrastructure.Casting) (infrastructuremolding.MoldingEnricher, error) + Forge(ctx context.Context, config infrastructure.Casting, p *pourer.Pourer) error + Cast(ctx context.Context, config infrastructure.Casting, outputPath string, p *pourer.Pourer) error +} diff --git a/internal/casting/infrastructure/planner.go b/internal/casting/infrastructure/planner.go new file mode 100644 index 00000000..8257615d --- /dev/null +++ b/internal/casting/infrastructure/planner.go @@ -0,0 +1,105 @@ +package infrastructure + +import ( + "context" + "log/slog" + "strings" + + "github.com/signoz/foundry/api/v1alpha1" + "github.com/signoz/foundry/api/v1alpha1/infrastructure" + "github.com/signoz/foundry/internal/domain" + foundryerrors "github.com/signoz/foundry/internal/errors" + infrastructuremolding "github.com/signoz/foundry/internal/molding/infrastructure" + "github.com/signoz/foundry/internal/molding/infrastructure/resourcemolding" + "github.com/signoz/foundry/internal/planner" + "github.com/signoz/foundry/internal/pourer" + "github.com/signoz/foundry/internal/tooler" +) + +var _ planner.Planner = (*Planner)(nil) + +// Planner is the Infrastructure Kind's per-Kind orchestrator. It satisfies +// the foundry planner contract by exposing this Kind's moldings, enricher, +// and casting strategy as verbs on a single value. +type Planner struct { + config *infrastructure.Casting + logger *slog.Logger + casting Casting + toolers []tooler.Tooler + enricher infrastructuremolding.MoldingEnricher + moldings []infrastructuremolding.Molding +} + +func NewPlanner(ctx context.Context, c *infrastructure.Casting, logger *slog.Logger) (planner.Planner, error) { + registry := NewRegistry(logger) + + castingStrategy, err := registry.Casting(c.Spec.Deployment) + if err != nil { + return nil, err + } + + toolers, err := registry.Toolers(c.Spec.Deployment) + if err != nil { + return nil, err + } + + enricher, err := castingStrategy.Enricher(ctx, c) + if err != nil { + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to get molding enricher") + } + + moldings := []infrastructuremolding.Molding{ + resourcemolding.New(logger), + } + + return &Planner{ + config: c, + logger: logger, + casting: castingStrategy, + toolers: toolers, + enricher: enricher, + moldings: moldings, + }, nil +} + +func (p *Planner) Machinery() v1alpha1.Machinery { return p.config } +func (p *Planner) Patches() []v1alpha1.PatchEntry { return p.config.Spec.Patches } + +func (p *Planner) MoldingKinds() []v1alpha1.MoldingKind { + kinds := make([]v1alpha1.MoldingKind, len(p.moldings)) + for i, m := range p.moldings { + kinds[i] = m.Kind() + } + return kinds +} + +func (p *Planner) EnrichStatus(ctx context.Context, kind v1alpha1.MoldingKind) error { + return p.enricher.EnrichStatus(ctx, kind, p.config) +} + +func (p *Planner) Mold(ctx context.Context, kind v1alpha1.MoldingKind) error { + for _, m := range p.moldings { + if m.Kind() == kind { + return m.MoldV1Alpha1(ctx, p.config) + } + } + return foundryerrors.Newf(foundryerrors.TypeInternal, "molding %q not registered for infrastructure planner", kind) +} + +func (p *Planner) MergeStatusIntoSpec() error { + return p.config.MergeStatusIntoSpec() +} + +func (p *Planner) Forge(ctx context.Context, target string) ([]domain.Material, error) { + pr := pourer.New(strings.ToLower(p.config.Kind().String())) + if err := p.casting.Forge(ctx, *p.config, pr); err != nil { + return nil, err + } + return pr.Pour() +} + +func (p *Planner) Cast(ctx context.Context, poursPath string) error { + return p.casting.Cast(ctx, *p.config, poursPath, pourer.New(strings.ToLower(p.config.Kind().String()))) +} + +func (p *Planner) Toolers() []tooler.Tooler { return p.toolers } diff --git a/internal/casting/infrastructure/registry.go b/internal/casting/infrastructure/registry.go new file mode 100644 index 00000000..110c1b53 --- /dev/null +++ b/internal/casting/infrastructure/registry.go @@ -0,0 +1,47 @@ +package infrastructure + +import ( + "log/slog" + + "github.com/signoz/foundry/api/v1alpha1" + foundryerrors "github.com/signoz/foundry/internal/errors" + "github.com/signoz/foundry/internal/tooler" +) + +type CastingItem struct { + Casting Casting + Toolers []tooler.Tooler +} + +type Registry struct { + castings map[v1alpha1.TypeDeployment]CastingItem +} + +func NewRegistry(logger *slog.Logger) *Registry { + return &Registry{ + castings: map[v1alpha1.TypeDeployment]CastingItem{}, + } +} + +// lookup matches the exact deployment; each platform, mode, and flavor +// combination registers its own casting. +func (registry *Registry) lookup(deployment v1alpha1.TypeDeployment) (CastingItem, bool) { + item, ok := registry.castings[deployment] + return item, ok +} + +func (registry *Registry) Casting(deployment v1alpha1.TypeDeployment) (Casting, error) { + item, ok := registry.lookup(deployment) + if !ok { + return nil, foundryerrors.Newf(foundryerrors.TypeUnsupported, "infrastructure deployment '%+v' is not supported", deployment) + } + return item.Casting, nil +} + +func (registry *Registry) Toolers(deployment v1alpha1.TypeDeployment) ([]tooler.Tooler, error) { + item, ok := registry.lookup(deployment) + if !ok { + return nil, foundryerrors.Newf(foundryerrors.TypeUnsupported, "infrastructure deployment '%+v' is not supported", deployment) + } + return item.Toolers, nil +} diff --git a/internal/casting/installation/planner.go b/internal/casting/installation/planner.go index 2a2faa44..c6cc0294 100644 --- a/internal/casting/installation/planner.go +++ b/internal/casting/installation/planner.go @@ -18,7 +18,6 @@ import ( "github.com/signoz/foundry/internal/molding/telemetrystoremolding" "github.com/signoz/foundry/internal/planner" "github.com/signoz/foundry/internal/tooler" - "github.com/signoz/foundry/internal/tooler/terraformtooler" ) var _ planner.Planner = (*Planner)(nil) @@ -109,9 +108,5 @@ func (p *Planner) Cast(ctx context.Context, poursPath string) error { } func (p *Planner) Toolers() []tooler.Tooler { - toolers := p.toolers - if p.config.Spec.Infrastructure.Enabled { - toolers = append(toolers, terraformtooler.New()) - } - return toolers + return p.toolers } diff --git a/internal/config/yamlconfig/config.go b/internal/config/yamlconfig/config.go index 520dabca..ca05ed46 100644 --- a/internal/config/yamlconfig/config.go +++ b/internal/config/yamlconfig/config.go @@ -9,6 +9,7 @@ import ( "github.com/signoz/foundry/api/v1alpha1" "github.com/signoz/foundry/api/v1alpha1/collectionagent" + "github.com/signoz/foundry/api/v1alpha1/infrastructure" "github.com/signoz/foundry/api/v1alpha1/installation" installationcompat "github.com/signoz/foundry/internal/compat/installation" "github.com/signoz/foundry/internal/config" @@ -28,6 +29,7 @@ func New(logger *slog.Logger) config.Config { c.loaders = map[v1alpha1.Kind]loaderFn{ v1alpha1.KindInstallation: c.loadInstallation, v1alpha1.KindCollectionAgent: c.loadCollectionAgent, + v1alpha1.KindInfrastructure: c.loadInfrastructure, } return c } @@ -126,6 +128,33 @@ func (*yamlConfig) loadCollectionAgent(bytes []byte, path string) (v1alpha1.Mach return base, nil } +func (*yamlConfig) loadInfrastructure(bytes []byte, path string) (v1alpha1.Machinery, error) { + var loaded infrastructure.Casting + if err := domain.UnmarshalYAML(bytes, &loaded); err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, "failed to unmarshal infrastructure casting") + } + + base := infrastructure.Default() + if err := v1alpha1.Merge(base, &loaded); err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, "failed to merge default infrastructure casting") + } + + contents, err := json.Marshal(base) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, "failed to marshal infrastructure casting") + } + toValidate := map[string]any{} + if err := json.Unmarshal(contents, &toValidate); err != nil { + return nil, errors.Wrapf(err, errors.TypeInternal, "failed to unmarshal infrastructure casting for validation") + } + + if err := infrastructure.Schema().Validate(toValidate); err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, "invalid casting file %s", path) + } + + return base, nil +} + // CreateV1Alpha1Lock writes the resolved casting to the lock file. func (*yamlConfig) CreateV1Alpha1Lock(ctx context.Context, machinery v1alpha1.Machinery, path string) error { contents, err := domain.MarshalYAML(machinery) @@ -165,6 +194,12 @@ func (*yamlConfig) GetV1Alpha1Lock(ctx context.Context, path string) (v1alpha1.M return nil, errors.Wrapf(err, errors.TypeInvalidInput, "failed to unmarshal collectionagent casting") } return &c, nil + case v1alpha1.KindInfrastructure: + var c infrastructure.Casting + if err := domain.UnmarshalYAML(bytes, &c); err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, "failed to unmarshal infrastructure casting") + } + return &c, nil } return nil, errors.Newf(errors.TypeUnsupported, "unknown casting kind %q", kind) } diff --git a/internal/config/yamlconfig/config_test.go b/internal/config/yamlconfig/config_test.go index e0bb0717..30cb864b 100644 --- a/internal/config/yamlconfig/config_test.go +++ b/internal/config/yamlconfig/config_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/signoz/foundry/api/v1alpha1" + "github.com/signoz/foundry/api/v1alpha1/infrastructure" "github.com/signoz/foundry/api/v1alpha1/installation" "github.com/signoz/foundry/internal/domain" "github.com/stretchr/testify/assert" @@ -370,3 +371,78 @@ func TestGetV1Alpha1Merge(t *testing.T) { }) } } + +func TestGetV1Alpha1Infrastructure(t *testing.T) { + tests := []struct { + name string + input string + pass bool + }{ + { + name: "Deployment_Valid", + input: ` +apiVersion: v1alpha1 +kind: Infrastructure +metadata: + name: signoz +spec: + deployment: + platform: ecs + mode: ec2 + flavor: terraform +`, + pass: true, + }, + { + name: "NameMissing_Invalid", + input: ` +apiVersion: v1alpha1 +kind: Infrastructure +metadata: {} +spec: + deployment: + platform: ecs + mode: ec2 + flavor: terraform +`, + pass: false, + }, + { + name: "UnknownPlatform_Invalid", + input: ` +apiVersion: v1alpha1 +kind: Infrastructure +metadata: + name: signoz +spec: + deployment: + platform: nowhere + mode: ec2 + flavor: terraform +`, + pass: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + castingPath := filepath.Join(t.TempDir(), "casting.yaml") + assert.NoError(t, os.WriteFile(castingPath, []byte(tt.input), 0644)) + + cfg := New(slog.New(slog.DiscardHandler)) + machinery, err := cfg.GetV1Alpha1(context.Background(), castingPath) + if !tt.pass { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + casting, ok := machinery.(*infrastructure.Casting) + assert.True(t, ok) + if !ok { + return + } + assert.Equal(t, v1alpha1.KindInfrastructure, casting.Kind()) + }) + } +} diff --git a/internal/contract/aws/derive.go b/internal/contract/aws/derive.go new file mode 100644 index 00000000..c3a91c0a --- /dev/null +++ b/internal/contract/aws/derive.go @@ -0,0 +1,116 @@ +package aws + +import ( + "maps" + "slices" + + "github.com/signoz/foundry/api/v1alpha1/infrastructure" + "github.com/signoz/foundry/internal/contract" + "github.com/signoz/foundry/internal/errors" +) + +// Stamper renders a descriptor into the resource it resolves to. Tags layer +// the declaration's cloudLabels first, then the labels, then the descriptor's +// own, so an operator cannot rename what a consumer matches on. +func Stamper(declaration *infrastructure.ResourceConfig, labels map[string]string) func(Descriptor) Resource { + base := map[string]string{} + maps.Copy(base, declaration.CloudLabels) + maps.Copy(base, labels) + + return func(resource Descriptor) Resource { + tags := maps.Clone(base) + maps.Copy(tags, resource.Tags()) + + return Resource{Name: resource.Name(), Tags: tags} + } +} + +// Networking derives the network every substrate shares: subnets, route tables +// and gateways. An adopted network keeps its owner's name and tags, and gets no +// gateway. +func Networking(s contract.Substrate, + named func(Descriptor) Resource, + declaration *infrastructure.ResourceConfig, +) (*NetworkResources, error) { + subnets := declaration.Networking.Subnets + + network := &NetworkResources{ + VPC: named(VPC(s)), + Subnets: make(map[string]SubnetResource, len(subnets)), + RouteTables: map[string]Resource{}, + NATGateways: map[string]NATGatewayResource{}, + } + + if id := declaration.Networking.NetworkID; id != "" { + network.VPC = Resource{ID: id} + } + + // A gateway serves the private subnet it is keyed by and sits in a public one + // in the same zone. Walked in key order so the choice is stable. + publicByZone := map[string]string{} + + for _, key := range slices.Sorted(maps.Keys(subnets)) { + subnet := subnets[key] + + if subnet.Type != contract.SubnetTypePublic.String() || subnet.ID != "" { + continue + } + + if _, ok := publicByZone[subnet.Zone]; !ok { + publicByZone[subnet.Zone] = key + } + } + + for _, key := range slices.Sorted(maps.Keys(subnets)) { + subnet := subnets[key] + + reference, err := contract.NewKey(key) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, "failed to derive subnet %q", key) + } + + subnetType, err := contract.ParseSubnetType(subnet.Type) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, "failed to derive subnet %q", key) + } + + // An adopted subnet keeps the operator's own routing. + if subnet.ID != "" { + network.Subnets[key] = SubnetResource{ID: subnet.ID, Public: subnetType.IsPublic()} + continue + } + + resource := named(Subnet(s, reference, subnetType)) + + network.Subnets[key] = SubnetResource{ + Name: resource.Name, + Tags: resource.Tags, + Public: subnetType.IsPublic(), + } + + network.RouteTables[key] = named(RouteTable(s, reference)) + + if subnetType.IsPublic() { + // Derived here: the gateway exists only if a public subnet does. + network.InternetGateway = named(InternetGateway(s)) + continue + } + + if subnet.Egress != "" { + network.NATGateways[key] = NATGatewayResource{ID: subnet.Egress} + continue + } + + gateway := named(NATGateway(s, reference)) + address := named(ElasticIP(s, reference)) + + network.NATGateways[key] = NATGatewayResource{ + Name: gateway.Name, + Tags: gateway.Tags, + Subnet: publicByZone[subnet.Zone], + Address: &address, + } + } + + return network, nil +} diff --git a/internal/contract/aws/ecs/derive.go b/internal/contract/aws/ecs/derive.go new file mode 100644 index 00000000..9e87a6ac --- /dev/null +++ b/internal/contract/aws/ecs/derive.go @@ -0,0 +1,161 @@ +// Package ecs derives the resource set for a substrate that owns its instances. +// A pinned group resolves to named nodes with named volumes, an elastic group +// to a pool the substrate scales. +package ecs + +import ( + "github.com/signoz/foundry/api/v1alpha1/infrastructure" + "github.com/signoz/foundry/internal/contract" + "github.com/signoz/foundry/internal/contract/aws" +) + +// What the substrate's own rules exist for. Each is a name segment. +var ( + purposeIntraCluster = contract.MustNewKey("intra-cluster") + purposeAllOutbound = contract.MustNewKey("all-outbound") +) + +// Resources is the settled declaration beside every name and tag derived from +// it. Templates interpolate it and assemble no name or tag of their own. +type Resources struct { + Declaration *infrastructure.ResourceConfig + + Cluster aws.Resource + + Roles map[string]aws.Resource + + Network *aws.NetworkResources + + SecurityGroup aws.Resource + + SecurityGroupRules map[string]aws.Resource + + InstanceProfile aws.Resource + + // A declared group resolves into one of these two: Pinned when the substrate + // owns each instance and its volume, Pools when it owns the pool alone. + Pinned map[string]PinnedGroup + + Pools map[string]PoolGroup + + IgnoredTags []string +} + +type PinnedGroup struct { + Declared infrastructure.ResourceConfigInstanceGroup + + Storage contract.StorageClass + + Selector map[string]string + + Nodes []Node +} + +// Node is one node of a pinned group. Its volume is stated inside it, so the +// two cannot land in different zones. +type Node struct { + Name string + + Ordinal int + + Subnet string + + Tags map[string]string + + Volume aws.Resource +} + +type PoolGroup struct { + Declared infrastructure.ResourceConfigInstanceGroup + + Storage contract.StorageClass + + Selector map[string]string + + Subnets []string + + LaunchTemplate aws.Resource + + AutoscalingGroup aws.Resource +} + +func Derive(s contract.Substrate, declaration *infrastructure.ResourceConfig, labels map[string]string) (*Resources, error) { + named := aws.Stamper(declaration, labels) + + resources := &Resources{ + Declaration: declaration, + Cluster: named(aws.Cluster(s)), + SecurityGroup: named(aws.SecurityGroup(s, aws.RoleTask)), + InstanceProfile: named(aws.InstanceProfile(s, aws.RoleNode)), + SecurityGroupRules: map[string]aws.Resource{ + purposeIntraCluster.String(): named(aws.SecurityGroupRule(s, aws.RoleTask, purposeIntraCluster)), + purposeAllOutbound.String(): named(aws.SecurityGroupRule(s, aws.RoleTask, purposeAllOutbound)), + }, + Roles: map[string]aws.Resource{}, + + // The claim tag is stamped after provisioning, so reconciling it would + // revert a live claim on every apply. + IgnoredTags: []string{aws.Tag(contract.TagKeyIdentities)}, + } + + // The node's own credential only, without which the agent cannot register + // the instance with the cluster. Workload identity belongs to the workload. + resources.Roles[aws.RoleNode.String()] = named(aws.IAMRole(s, aws.RoleNode)) + + network, err := aws.Networking(s, named, declaration) + if err != nil { + return nil, err + } + + resources.Network = network + + placed, err := aws.PlaceInstanceGroups(declaration) + if err != nil { + return nil, err + } + + resources.Pinned = map[string]PinnedGroup{} + resources.Pools = map[string]PoolGroup{} + + for _, placement := range placed { + selector := aws.Filter(s.Select().WithStorage(placement.Storage)) + + if !placement.Storage.IsPinned() { + resources.Pools[placement.Key] = PoolGroup{ + Declared: placement.Declared, + Storage: placement.Storage, + Selector: selector, + Subnets: placement.Subnets, + LaunchTemplate: named(aws.LaunchTemplate(s, placement.Group)), + AutoscalingGroup: named(aws.AutoscalingGroup(s, placement.Group)), + } + + continue + } + + group := PinnedGroup{Declared: placement.Declared, Storage: placement.Storage, Selector: selector} + + // Each node carries its volume. The two cannot land in different zones. + nodes := 0 + + if placement.Declared.MinSize != nil { + nodes = *placement.Declared.MinSize + } + + for ordinal := range nodes { + node := named(aws.Node(s, placement.Group, ordinal)) + + group.Nodes = append(group.Nodes, Node{ + Name: node.Name, + Ordinal: ordinal, + Subnet: placement.Subnets[ordinal%len(placement.Subnets)], + Tags: node.Tags, + Volume: named(aws.Volume(s, placement.Group, ordinal)), + }) + } + + resources.Pinned[placement.Key] = group + } + + return resources, nil +} diff --git a/internal/contract/aws/ecs/derive_test.go b/internal/contract/aws/ecs/derive_test.go new file mode 100644 index 00000000..eb7412f5 --- /dev/null +++ b/internal/contract/aws/ecs/derive_test.go @@ -0,0 +1,222 @@ +package ecs + +import ( + "testing" + + "github.com/signoz/foundry/api/v1alpha1/infrastructure" + "github.com/signoz/foundry/internal/contract" + "github.com/signoz/foundry/internal/contract/aws" + "github.com/signoz/foundry/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const oneZone = `networking: + networkCIDR: 10.0.0.0/16 + subnets: + private-a: {type: private, zone: us-east-1a, cidr: 10.0.0.0/19} + public-a: {type: public, zone: us-east-1a, cidr: 10.0.96.0/22} +instanceGroups: + persistent: {storage: persistent, machineType: m5.large, minSize: 3, maxSize: 3} + ephemeral: {storage: ephemeral, machineType: c5.large, minSize: 1, maxSize: 1} +` + +const twoZones = `networking: + networkCIDR: 10.0.0.0/16 + subnets: + private-a: {type: private, zone: us-east-1a, cidr: 10.0.0.0/19} + private-b: {type: private, zone: us-east-1b, cidr: 10.0.32.0/19} + public-a: {type: public, zone: us-east-1a, cidr: 10.0.96.0/22} + public-b: {type: public, zone: us-east-1b, cidr: 10.0.100.0/22} +instanceGroups: + persistent: {storage: persistent, machineType: m5.large, minSize: 3, maxSize: 3} + ephemeral: {storage: ephemeral, machineType: c5.large, minSize: 1, maxSize: 1} +` + +// deriveData renders a declaration written the way the molding settles it. +func deriveData(t *testing.T, declaration string) (*Resources, error) { + t.Helper() + + config := &infrastructure.ResourceConfig{} + require.NoError(t, domain.UnmarshalYAML([]byte(declaration), config)) + + return Derive(contract.MustNewSubstrate("foundry"), config, nil) +} + +func mustDeriveData(t *testing.T, declaration string) *Resources { + t.Helper() + + derived, err := deriveData(t, declaration) + require.NoError(t, err) + + return derived +} + +// These names reach live infrastructure. Changing one replaces the resource it +// belongs to rather than updating it. +func TestTemplateData(t *testing.T) { + derived := mustDeriveData(t, oneZone) + + tests := []struct { + name string + of func(*Resources) string + expectedName string + }{ + {name: "Cluster_Unqualified", of: func(d *Resources) string { return d.Cluster.Name }, expectedName: "foundry-cls"}, + {name: "VPC_Unqualified", of: func(d *Resources) string { return d.Network.VPC.Name }, expectedName: "foundry-vpc"}, + {name: "InternetGateway_Unqualified", of: func(d *Resources) string { return d.Network.InternetGateway.Name }, expectedName: "foundry-igw"}, + {name: "Subnet_Keyed", of: func(d *Resources) string { return d.Network.Subnets["private-a"].Name }, expectedName: "foundry-sub-private-a"}, + {name: "RouteTable_Keyed", of: func(d *Resources) string { return d.Network.RouteTables["public-a"].Name }, expectedName: "foundry-rt-public-a"}, + {name: "NATGateway_KeyedByTheSubnetItServes", of: func(d *Resources) string { return d.Network.NATGateways["private-a"].Name }, expectedName: "foundry-nat-private-a"}, + {name: "ElasticIP_KeyedByTheSubnetItServes", of: func(d *Resources) string { return d.Network.NATGateways["private-a"].Address.Name }, expectedName: "foundry-eip-private-a"}, + {name: "SecurityGroup_Role", of: func(d *Resources) string { return d.SecurityGroup.Name }, expectedName: "foundry-sg-task"}, + {name: "SecurityGroupRule_RoleAndPurpose", of: func(d *Resources) string { + return d.SecurityGroupRules["intra-cluster"].Name + }, expectedName: "foundry-sg-task-intra-cluster"}, + {name: "Role_NodeOnly", of: func(d *Resources) string { return d.Roles["node"].Name }, expectedName: "foundry-iam-node"}, + {name: "InstanceProfile_Role", of: func(d *Resources) string { return d.InstanceProfile.Name }, expectedName: "foundry-prf-node"}, + {name: "LaunchTemplate_GroupKey", of: func(d *Resources) string { + return d.Pools["ephemeral"].LaunchTemplate.Name + }, expectedName: "foundry-lt-ephemeral"}, + {name: "AutoscalingGroup_GroupKey", of: func(d *Resources) string { + return d.Pools["ephemeral"].AutoscalingGroup.Name + }, expectedName: "foundry-asg-ephemeral"}, + {name: "Node_GroupKeyAndOrdinal", of: func(d *Resources) string { + return d.Pinned["persistent"].Nodes[0].Name + }, expectedName: "foundry-node-persistent-0"}, + {name: "Volume_GroupKeyAndOrdinal", of: func(d *Resources) string { + return d.Pinned["persistent"].Nodes[0].Volume.Name + }, expectedName: "foundry-vol-persistent-0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedName, tt.of(derived)) + }) + } +} + +// The tags a consuming casting filters on are the only ones whose spelling live +// infrastructure depends on. +func TestTemplateDataContractTags(t *testing.T) { + derived := mustDeriveData(t, oneZone) + + assert.Equal(t, "private", derived.Network.Subnets["private-a"].Tags[aws.Tag(contract.TagKeySubnetType)]) + assert.Equal(t, "public", derived.Network.Subnets["public-a"].Tags[aws.Tag(contract.TagKeySubnetType)]) + assert.Equal(t, "persistent", derived.Pinned["persistent"].Nodes[0].Tags[aws.Tag(contract.TagKeyStorage)]) + assert.Equal(t, "persistent", derived.Pinned["persistent"].Nodes[0].Volume.Tags[aws.Tag(contract.TagKeyStorage)]) + + assert.Equal(t, map[string]string{ + aws.Tag(contract.TagKeyName): "foundry", + aws.Tag(contract.TagKeyStorage): "persistent", + }, derived.Pinned["persistent"].Selector) + + // The claim tag is stamped after provisioning, so a casting that reconciles + // has to be told to leave it alone. + assert.Equal(t, []string{aws.Tag(contract.TagKeyIdentities)}, derived.IgnoredTags) +} + +// A group's selector has to match what its own nodes are stamped with, or the +// substrate advertises a placement nothing satisfies. +func TestTemplateDataSelectorMatchesItsNodes(t *testing.T) { + group := mustDeriveData(t, oneZone).Pinned["persistent"] + + for _, node := range group.Nodes { + for key, value := range group.Selector { + assert.Equal(t, value, node.Tags[key], "node %s does not match the group selector on %s", node.Name, key) + } + } +} + +// Foundry's ownership labels and the derived tags are what a consumer matches +// on, so an operator's own tags sit underneath rather than over them. +func TestTemplateDataCloudLabelsDoNotOverrideTheContract(t *testing.T) { + derived := mustDeriveData(t, oneZone+"cloudLabels:\n team: observability\n "+aws.Tag(contract.TagKeyName)+": not-foundry\n") + + assert.Equal(t, "observability", derived.Network.VPC.Tags["team"]) + assert.Equal(t, "foundry", derived.Network.VPC.Tags[aws.Tag(contract.TagKeyName)]) +} + +// Nodes are laid out across the group's subnets in ordinal order, and a node's +// volume goes wherever the node does. +func TestTemplateDataPlacementCyclesThroughSubnets(t *testing.T) { + derived := mustDeriveData(t, twoZones) + + group := derived.Pinned["persistent"] + assert.Equal(t, []string{"private-a", "private-b", "private-a"}, []string{ + group.Nodes[0].Subnet, group.Nodes[1].Subnet, group.Nodes[2].Subnet, + }) + + // Each zone's gateway sits in a public subnet of that same zone. + assert.Equal(t, "public-a", derived.Network.NATGateways["private-a"].Subnet) + assert.Equal(t, "public-b", derived.Network.NATGateways["private-b"].Subnet) +} + +// A group that names its own subnets is placed only there. +func TestTemplateDataHonourStatedPlacement(t *testing.T) { + group := mustDeriveData(t, twoZones+" persistent: {storage: persistent, machineType: m5.large, minSize: 3, maxSize: 3, subnets: [private-b]}\n").Pinned["persistent"] + + assert.Len(t, group.Nodes, 3) + + for _, node := range group.Nodes { + assert.Equal(t, "private-b", node.Subnet) + } +} + +// An adopted network is referenced, never described: foundry adds no gateway, +// no route table and no tags to something it does not own. +func TestTemplateDataAdoptedNetwork(t *testing.T) { + derived := mustDeriveData(t, `networking: + networkID: vpc-0a1b2c3d + subnets: + private-a: {type: private, zone: us-east-1a, id: subnet-0a1b2c3d} +instanceGroups: + persistent: {storage: persistent, machineType: m5.large, minSize: 3, maxSize: 3} +`) + + assert.Equal(t, "vpc-0a1b2c3d", derived.Network.VPC.ID) + assert.Empty(t, derived.Network.VPC.Name) + assert.Empty(t, derived.Network.VPC.Tags) + assert.Empty(t, derived.Network.InternetGateway.Name) + assert.Empty(t, derived.Network.RouteTables) + assert.Empty(t, derived.Network.NATGateways) + + assert.Equal(t, "subnet-0a1b2c3d", derived.Network.Subnets["private-a"].ID) + assert.Empty(t, derived.Network.Subnets["private-a"].Name) + + // The compute placed in it is still foundry's. + assert.Equal(t, "foundry-node-persistent-0", derived.Pinned["persistent"].Nodes[0].Name) +} + +// A private subnet that already routes somewhere gets no gateway of its own, +// and the id it routes through is carried through for the casting to reference. +func TestTemplateDataAdoptedEgress(t *testing.T) { + derived := mustDeriveData(t, `networking: + networkCIDR: 10.0.0.0/16 + subnets: + private-b: {type: private, zone: us-east-1b, cidr: 10.0.32.0/19, egress: nat-0a1b2c3d} +instanceGroups: + ephemeral: {storage: ephemeral, machineType: c5.large, minSize: 1, maxSize: 1} +`) + + gateway := derived.Network.NATGateways["private-b"] + assert.Equal(t, "nat-0a1b2c3d", gateway.ID) + assert.Empty(t, gateway.Name) + assert.Nil(t, gateway.Address) + + // No public subnet was declared, so nothing routes to a gateway foundry owns. + assert.Empty(t, derived.Network.InternetGateway.Name) +} + +// A group with nowhere to go would provision nodes no workload can be placed +// on, so it fails instead. +func TestTemplateDataWithoutAPrivateSubnet(t *testing.T) { + _, err := deriveData(t, `networking: + subnets: + public-a: {type: public, zone: us-east-1a, cidr: 10.0.96.0/22} +instanceGroups: + ephemeral: {storage: ephemeral, machineType: c5.large, minSize: 1, maxSize: 1} +`) + + assert.Error(t, err) +} diff --git a/internal/contract/aws/eks/derive.go b/internal/contract/aws/eks/derive.go new file mode 100644 index 00000000..a695929b --- /dev/null +++ b/internal/contract/aws/eks/derive.go @@ -0,0 +1,151 @@ +// Package eks derives the resource set for a substrate whose nodes a managed +// control plane owns. The provider scales each pool and replaces nodes within +// it, so the substrate names the pool and never a node or a volume in it. +// Persistent volumes are provisioned by the workload's own platform against the +// storage class its group advertises. +package eks + +import ( + "maps" + + "github.com/signoz/foundry/api/v1alpha1/infrastructure" + "github.com/signoz/foundry/internal/contract" + "github.com/signoz/foundry/internal/contract/aws" + "github.com/signoz/foundry/internal/errors" +) + +// Tags the kubernetes ecosystem reads off a subnet, not facts foundry stamps. +// A load balancer controller picks its subnets by these and looks for no +// foundry-prefixed spelling. +const ( + tagRoleELB = "kubernetes.io/role/elb" + tagRoleInternalELB = "kubernetes.io/role/internal-elb" + tagClusterPrefix = "kubernetes.io/cluster/" +) + +// tagRoleValue is what the controller expects. Only the key carries meaning. +const tagRoleValue = "1" + +// minimumZones is how many availability zones a managed control plane places +// its own interfaces across. +const minimumZones = 2 + +// Resources is the settled declaration beside every name and tag derived from +// it. Templates interpolate it and assemble no name or tag of their own. +type Resources struct { + Declaration *infrastructure.ResourceConfig + + Cluster aws.Resource + + Roles map[string]aws.Resource + + Network *aws.NetworkResources + + Groups map[string]Group + + IgnoredTags []string +} + +// Group is a pool the provider scales and replaces nodes in. No node in it is +// named, and no volume is attached here. +type Group struct { + Declared infrastructure.ResourceConfigInstanceGroup + + Storage contract.StorageClass + + Selector map[string]string + + Subnets []string + + NodeGroup aws.Resource +} + +func Derive(s contract.Substrate, declaration *infrastructure.ResourceConfig, labels map[string]string) (*Resources, error) { + if err := checkZoneSpread(declaration); err != nil { + return nil, err + } + + named := aws.Stamper(declaration, labels) + + resources := &Resources{ + Declaration: declaration, + Cluster: named(aws.Cluster(s)), + Roles: map[string]aws.Resource{}, + + // The control plane stamps this tag on what it discovers, so reconciling + // it would revert a live cluster's claim on every apply. + IgnoredTags: []string{tagClusterPrefix + aws.Cluster(s).Name()}, + } + + // The control plane assumes the first to manage the cluster, a node the + // second to register with it, and the storage driver the third through pod + // identity. None belongs to a tenant workload. + resources.Roles[aws.RoleCluster.String()] = named(aws.IAMRole(s, aws.RoleCluster)) + resources.Roles[aws.RoleNode.String()] = named(aws.IAMRole(s, aws.RoleNode)) + resources.Roles[aws.RoleEBSCSI.String()] = named(aws.IAMRole(s, aws.RoleEBSCSI)) + + network, err := aws.Networking(s, named, declaration) + if err != nil { + return nil, err + } + + electSubnetsForLoadBalancers(network) + resources.Network = network + + placed, err := aws.PlaceInstanceGroups(declaration) + if err != nil { + return nil, err + } + + // One pool per declared group. Bounds and machine type stay on the + // declaration; the pool's name, its node tag match and its placement are + // derived here. + resources.Groups = make(map[string]Group, len(placed)) + + for _, placement := range placed { + resources.Groups[placement.Key] = Group{ + Declared: placement.Declared, + Storage: placement.Storage, + Selector: aws.Filter(s.Select().WithStorage(placement.Storage)), + Subnets: placement.Subnets, + NodeGroup: named(aws.NodeGroup(s, placement.Group)), + } + } + + return resources, nil +} + +func checkZoneSpread(declaration *infrastructure.ResourceConfig) error { + zones := map[string]struct{}{} + for _, subnet := range declaration.Networking.Subnets { + zones[subnet.Zone] = struct{}{} + } + + if len(zones) < minimumZones { + return errors.Newf(errors.TypeInvalidInput, "failed to derive substrate: a managed control plane places its interfaces across at least %d availability zones, and the subnets declare %d", minimumZones, len(zones)) + } + + return nil +} + +// electSubnetsForLoadBalancers marks which subnets a load balancer may be +// placed in: an internet-facing one in a public subnet, an internal one in a +// private subnet. An adopted subnet keeps its owner's tags and is left alone. +func electSubnetsForLoadBalancers(network *aws.NetworkResources) { + for key, subnet := range network.Subnets { + if subnet.Tags == nil { + continue + } + + role := tagRoleInternalELB + if subnet.Public { + role = tagRoleELB + } + + tags := maps.Clone(subnet.Tags) + tags[role] = tagRoleValue + + subnet.Tags = tags + network.Subnets[key] = subnet + } +} diff --git a/internal/contract/aws/eks/derive_test.go b/internal/contract/aws/eks/derive_test.go new file mode 100644 index 00000000..d97c35ac --- /dev/null +++ b/internal/contract/aws/eks/derive_test.go @@ -0,0 +1,181 @@ +package eks + +import ( + "maps" + "slices" + "testing" + + "github.com/signoz/foundry/api/v1alpha1/infrastructure" + "github.com/signoz/foundry/internal/contract" + "github.com/signoz/foundry/internal/contract/aws" + "github.com/signoz/foundry/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const oneZone = `networking: + networkCIDR: 10.0.0.0/16 + subnets: + private-a: {type: private, zone: us-east-1a, cidr: 10.0.0.0/19} + public-a: {type: public, zone: us-east-1a, cidr: 10.0.96.0/22} +instanceGroups: + persistent: {storage: persistent, machineType: m5.large, minSize: 3, maxSize: 3} + ephemeral: {storage: ephemeral, machineType: c5.large, minSize: 1, maxSize: 1} +` + +const twoZones = `networking: + networkCIDR: 10.0.0.0/16 + subnets: + private-a: {type: private, zone: us-east-1a, cidr: 10.0.0.0/19} + private-b: {type: private, zone: us-east-1b, cidr: 10.0.32.0/19} + public-a: {type: public, zone: us-east-1a, cidr: 10.0.96.0/22} + public-b: {type: public, zone: us-east-1b, cidr: 10.0.100.0/22} +instanceGroups: + persistent: {storage: persistent, machineType: m5.large, minSize: 3, maxSize: 3} + ephemeral: {storage: ephemeral, machineType: c5.large, minSize: 1, maxSize: 1} +` + +// deriveData renders a declaration written the way the molding settles it. +func deriveData(t *testing.T, declaration string) (*Resources, error) { + t.Helper() + + config := &infrastructure.ResourceConfig{} + require.NoError(t, domain.UnmarshalYAML([]byte(declaration), config)) + + return Derive(contract.MustNewSubstrate("foundry"), config, nil) +} + +func mustDeriveData(t *testing.T, declaration string) *Resources { + t.Helper() + + derived, err := deriveData(t, declaration) + require.NoError(t, err) + + return derived +} + +// These names reach live infrastructure. Changing one replaces the resource it +// belongs to rather than updating it. +func TestTemplateData(t *testing.T) { + derived := mustDeriveData(t, twoZones) + + tests := []struct { + name string + of func(*Resources) string + expectedName string + }{ + {name: "Cluster_Unqualified", of: func(d *Resources) string { return d.Cluster.Name }, expectedName: "foundry-cls"}, + {name: "VPC_Unqualified", of: func(d *Resources) string { return d.Network.VPC.Name }, expectedName: "foundry-vpc"}, + {name: "InternetGateway_Unqualified", of: func(d *Resources) string { return d.Network.InternetGateway.Name }, expectedName: "foundry-igw"}, + {name: "Subnet_Keyed", of: func(d *Resources) string { return d.Network.Subnets["private-a"].Name }, expectedName: "foundry-sub-private-a"}, + {name: "NATGateway_KeyedByTheSubnetItServes", of: func(d *Resources) string { return d.Network.NATGateways["private-a"].Name }, expectedName: "foundry-nat-private-a"}, + {name: "Role_ControlPlane", of: func(d *Resources) string { return d.Roles["cluster"].Name }, expectedName: "foundry-iam-cluster"}, + {name: "Role_Node", of: func(d *Resources) string { return d.Roles["node"].Name }, expectedName: "foundry-iam-node"}, + {name: "Role_StorageDriver", of: func(d *Resources) string { return d.Roles["ebs-csi"].Name }, expectedName: "foundry-iam-ebs-csi"}, + {name: "NodeGroup_GroupKey", of: func(d *Resources) string { + return d.Groups["persistent"].NodeGroup.Name + }, expectedName: "foundry-ng-persistent"}, + {name: "NodeGroup_ScalingGroupKey", of: func(d *Resources) string { + return d.Groups["ephemeral"].NodeGroup.Name + }, expectedName: "foundry-ng-ephemeral"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedName, tt.of(derived)) + }) + } +} + +// The provider owns every node, including a stateful group's. The data has no +// field for a node, a volume, a launch template or an autoscaling group, so +// what remains to check is that every group resolves to a named pool. +func TestTemplateDataNamesOnlyThePool(t *testing.T) { + derived := mustDeriveData(t, twoZones) + + for key, group := range derived.Groups { + assert.NotEmpty(t, group.NodeGroup.Name, "group %s names no pool", key) + } +} + +// The substrate's roles are its own: control plane, node, and storage driver. +// A tenant workload's identity is not derived here; an instance profile or a +// security group of the substrate's making has no field to exist in. +func TestTemplateDataLeavesWorkloadIdentityAlone(t *testing.T) { + derived := mustDeriveData(t, twoZones) + + assert.Equal(t, []string{"cluster", "ebs-csi", "node"}, slices.Sorted(maps.Keys(derived.Roles))) +} + +// The tags a consuming casting filters on are the only ones whose spelling live +// infrastructure depends on. +func TestTemplateDataContractTags(t *testing.T) { + derived := mustDeriveData(t, twoZones) + + assert.Equal(t, "private", derived.Network.Subnets["private-a"].Tags[aws.Tag(contract.TagKeySubnetType)]) + assert.Equal(t, "public", derived.Network.Subnets["public-a"].Tags[aws.Tag(contract.TagKeySubnetType)]) + + assert.Equal(t, map[string]string{ + aws.Tag(contract.TagKeyName): "foundry", + aws.Tag(contract.TagKeyStorage): "persistent", + }, derived.Groups["persistent"].Selector) + + // The control plane stamps its own ownership tag on what it discovers, so a + // casting that reconciles has to be told to leave it alone. + assert.Equal(t, []string{tagClusterPrefix + "foundry-cls"}, derived.IgnoredTags) +} + +// A load balancer controller picks its subnets by these, and puts an +// internet-facing balancer in a public subnet and an internal one in a private. +func TestTemplateDataElectsSubnetsForLoadBalancers(t *testing.T) { + derived := mustDeriveData(t, twoZones) + + assert.Equal(t, tagRoleValue, derived.Network.Subnets["public-a"].Tags[tagRoleELB]) + assert.NotContains(t, derived.Network.Subnets["public-a"].Tags, tagRoleInternalELB) + + assert.Equal(t, tagRoleValue, derived.Network.Subnets["private-a"].Tags[tagRoleInternalELB]) + assert.NotContains(t, derived.Network.Subnets["private-a"].Tags, tagRoleELB) +} + +// An adopted subnet is not the substrate's to tag, so it carries whatever its +// owner already put on it. +func TestTemplateDataAdoptedSubnetsAreNotTagged(t *testing.T) { + derived := mustDeriveData(t, `networking: + networkID: vpc-0a1b2c3d + subnets: + private-a: {type: private, zone: us-east-1a, id: subnet-0a1b2c3d} + private-b: {type: private, zone: us-east-1b, id: subnet-1b2c3d4e} + public-a: {type: public, zone: us-east-1a, id: subnet-4e5f6a7b} +instanceGroups: + ephemeral: {storage: ephemeral, machineType: c5.large, minSize: 1, maxSize: 1} +`) + + assert.Empty(t, derived.Network.Subnets["private-a"].Tags) + assert.Empty(t, derived.Network.Subnets["public-a"].Tags) + assert.Equal(t, "vpc-0a1b2c3d", derived.Network.VPC.ID) + assert.Empty(t, derived.Network.VPC.Name) +} + +// A group placed nowhere would be a pool the provider cannot start a node in. +func TestTemplateDataWithoutAPrivateSubnet(t *testing.T) { + _, err := deriveData(t, `networking: + networkCIDR: 10.0.0.0/16 + subnets: + public-a: {type: public, zone: us-east-1a, cidr: 10.0.96.0/22} + public-b: {type: public, zone: us-east-1b, cidr: 10.0.100.0/22} +instanceGroups: + ephemeral: {storage: ephemeral, machineType: c5.large, minSize: 1, maxSize: 1} +`) + + require.Error(t, err) + assert.Contains(t, err.Error(), "there is no private subnet to place it in") +} + +// The control plane's own interfaces are spread by the provider, which refuses +// a cluster it cannot spread. Failing here beats failing on apply. +func TestTemplateDataWithinOneZone(t *testing.T) { + _, err := deriveData(t, oneZone) + + require.Error(t, err) + assert.Contains(t, err.Error(), "at least 2 availability zones") +} diff --git a/internal/contract/aws/group.go b/internal/contract/aws/group.go new file mode 100644 index 00000000..be728450 --- /dev/null +++ b/internal/contract/aws/group.go @@ -0,0 +1,68 @@ +package aws + +import ( + "maps" + "slices" + + "github.com/signoz/foundry/api/v1alpha1/infrastructure" + "github.com/signoz/foundry/internal/contract" + "github.com/signoz/foundry/internal/errors" +) + +// PlacedGroup is one declared instance group with the subnets its nodes go in +// resolved, either as stated or, when the group states none, every private one. +type PlacedGroup struct { + Key string + Declared infrastructure.ResourceConfigInstanceGroup + Storage contract.StorageClass + Group contract.NodeGroup + Subnets []string +} + +// PlaceInstanceGroups resolves every declared group in key order. +func PlaceInstanceGroups(declaration *infrastructure.ResourceConfig) ([]PlacedGroup, error) { + // The fallback placement for a group that names no subnet. + placement := []string{} + + for _, key := range slices.Sorted(maps.Keys(declaration.Networking.Subnets)) { + if declaration.Networking.Subnets[key].Type != contract.SubnetTypePublic.String() { + placement = append(placement, key) + } + } + + placed := make([]PlacedGroup, 0, len(declaration.InstanceGroups)) + + for _, key := range slices.Sorted(maps.Keys(declaration.InstanceGroups)) { + declared := declaration.InstanceGroups[key] + + reference, err := contract.NewKey(key) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, "failed to derive instance group %q", key) + } + + storage, err := contract.ParseStorageClass(declared.Storage) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, "failed to derive instance group %q", key) + } + + subnets := declared.Subnets + + if len(subnets) == 0 { + subnets = placement + } + + if len(subnets) == 0 { + return nil, errors.Newf(errors.TypeInvalidInput, "failed to derive instance group %q: there is no private subnet to place it in", key) + } + + placed = append(placed, PlacedGroup{ + Key: key, + Declared: declared, + Storage: storage, + Group: contract.NewNodeGroup(reference, storage), + Subnets: subnets, + }) + } + + return placed, nil +} diff --git a/internal/contract/aws/group_test.go b/internal/contract/aws/group_test.go new file mode 100644 index 00000000..e227dc58 --- /dev/null +++ b/internal/contract/aws/group_test.go @@ -0,0 +1,87 @@ +package aws + +import ( + "testing" + + "github.com/signoz/foundry/api/v1alpha1/infrastructure" + "github.com/signoz/foundry/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPlaceInstanceGroups(t *testing.T) { + tests := []struct { + name string + declaration string + pass bool + expectedSubnets map[string][]string + }{ + { + name: "NoStatedPlacement_EveryPrivateSubnet", + declaration: `networking: + subnets: + private-a: {type: private, zone: us-east-1a} + private-b: {type: private, zone: us-east-1b} + public-a: {type: public, zone: us-east-1a} +instanceGroups: + ephemeral: {storage: ephemeral, minSize: 1, maxSize: 1} +`, + pass: true, + expectedSubnets: map[string][]string{"ephemeral": {"private-a", "private-b"}}, + }, + { + name: "StatedPlacement_Honoured", + declaration: `networking: + subnets: + private-a: {type: private, zone: us-east-1a} + private-b: {type: private, zone: us-east-1b} +instanceGroups: + persistent: {storage: persistent, minSize: 3, maxSize: 3, subnets: [private-b]} +`, + pass: true, + expectedSubnets: map[string][]string{"persistent": {"private-b"}}, + }, + { + name: "NoPrivateSubnet_Invalid", + declaration: `networking: + subnets: + public-a: {type: public, zone: us-east-1a} +instanceGroups: + ephemeral: {storage: ephemeral, minSize: 1, maxSize: 1} +`, + pass: false, + }, + { + name: "MalformedGroupKey_Invalid", + declaration: `networking: + subnets: + private-a: {type: private, zone: us-east-1a} +instanceGroups: + Ephemeral: {storage: ephemeral, minSize: 1, maxSize: 1} +`, + pass: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + declaration := &infrastructure.ResourceConfig{} + require.NoError(t, domain.UnmarshalYAML([]byte(tt.declaration), declaration)) + + placed, err := PlaceInstanceGroups(declaration) + if !tt.pass { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + + for _, placement := range placed { + assert.Equal(t, tt.expectedSubnets[placement.Key], placement.Subnets) + assert.Equal(t, placement.Storage, placement.Group.Storage()) + assert.Equal(t, placement.Declared.Storage, placement.Storage.String()) + assert.Equal(t, placement.Key, placement.Group.Key().String()) + } + }) + } +} diff --git a/internal/contract/aws/resource.go b/internal/contract/aws/resource.go new file mode 100644 index 00000000..1ad6b2ac --- /dev/null +++ b/internal/contract/aws/resource.go @@ -0,0 +1,202 @@ +package aws + +import ( + "github.com/signoz/foundry/internal/contract" + "strconv" + "strings" +) + +// Descriptor describes one thing a substrate provisions; its name, tags and +// selection all derive from it. Each resource type has its own constructor. +type Descriptor struct { + substrate contract.Substrate + resourceType resourceType + + key contract.Key + purpose contract.Key + subnetType contract.SubnetType + storage contract.StorageClass + role Role + ordinal int + + ownership contract.Ownership + identities contract.Identities +} + +func Cluster(s contract.Substrate) Descriptor { + return Descriptor{substrate: s, resourceType: typeCluster} +} + +func VPC(s contract.Substrate) Descriptor { + return Descriptor{substrate: s, resourceType: typeVPC} +} + +func InternetGateway(s contract.Substrate) Descriptor { + return Descriptor{substrate: s, resourceType: typeInternetGateway} +} + +// Subnet takes its type separately from its key. The key is the operator's own +// reference and says nothing a consumer can rely on. +func Subnet(s contract.Substrate, key contract.Key, subnetType contract.SubnetType) Descriptor { + return Descriptor{substrate: s, resourceType: typeSubnet, key: key, subnetType: subnetType} +} + +func RouteTable(s contract.Substrate, key contract.Key) Descriptor { + return Descriptor{substrate: s, resourceType: typeRouteTable, key: key} +} + +func NATGateway(s contract.Substrate, key contract.Key) Descriptor { + return Descriptor{substrate: s, resourceType: typeNATGateway, key: key} +} + +func ElasticIP(s contract.Substrate, key contract.Key) Descriptor { + return Descriptor{substrate: s, resourceType: typeElasticIP, key: key} +} + +func SecurityGroup(s contract.Substrate, role Role) Descriptor { + return Descriptor{substrate: s, resourceType: typeSecurityGroup, role: role} +} + +// SecurityGroupRule is one rule of the group for the same role, distinguished +// by what it admits. +func SecurityGroupRule(s contract.Substrate, role Role, purpose contract.Key) Descriptor { + return Descriptor{substrate: s, resourceType: typeSecurityGroup, role: role, purpose: purpose} +} + +func IAMRole(s contract.Substrate, role Role) Descriptor { + return Descriptor{substrate: s, resourceType: typeRole, role: role} +} + +// IAMRolePolicy is a policy inline on a role, distinguished by what it grants. +func IAMRolePolicy(s contract.Substrate, role Role, purpose contract.Key) Descriptor { + return Descriptor{substrate: s, resourceType: typeRole, role: role, purpose: purpose} +} + +func InstanceProfile(s contract.Substrate, role Role) Descriptor { + return Descriptor{substrate: s, resourceType: typeInstanceProfile, role: role} +} + +func LaunchTemplate(s contract.Substrate, group contract.NodeGroup) Descriptor { + return Descriptor{substrate: s, resourceType: typeLaunchTemplate, key: group.Key(), storage: group.Storage()} +} + +func AutoscalingGroup(s contract.Substrate, group contract.NodeGroup) Descriptor { + return Descriptor{substrate: s, resourceType: typeAutoscalingGroup, key: group.Key(), storage: group.Storage()} +} + +// NodeGroup is a pool the provider scales and replaces nodes in on the +// substrate's behalf, holding no name for any node of its own. +func NodeGroup(s contract.Substrate, group contract.NodeGroup) Descriptor { + return Descriptor{substrate: s, resourceType: typeNodeGroup, key: group.Key(), storage: group.Storage()} +} + +func Node(s contract.Substrate, group contract.NodeGroup, ordinal int) Descriptor { + return Descriptor{substrate: s, resourceType: typeNode, key: group.Key(), storage: group.Storage(), ordinal: ordinal} +} + +func Volume(s contract.Substrate, group contract.NodeGroup, ordinal int) Descriptor { + return Descriptor{substrate: s, resourceType: typeVolume, key: group.Key(), storage: group.Storage(), ordinal: ordinal} +} + +// WithOwnership marks a resource adopted rather than created. The derived name +// is unaffected. +func (r Descriptor) WithOwnership(ownership contract.Ownership) Descriptor { + r.ownership = ownership + + return r +} + +// WithClaims records the identities holding a volume. +func (r Descriptor) WithClaims(identities contract.Identities) Descriptor { + r.identities = identities + + return r +} + +// Name is -[-...]. A qualifier that does not apply +// to the resource type is left out. +func (r Descriptor) Name() string { + parts := make([]string, 0, len(r.resourceType.qualifiers)+2) + parts = append(parts, r.substrate.String(), r.resourceType.String()) + + for _, qualifier := range r.resourceType.qualifiers { + if segment := qualifier.of(r); segment != "" { + parts = append(parts, segment) + } + } + + return strings.Join(parts, "-") +} + +// Selection is the set that finds exactly this resource. +func (r Descriptor) Selection() contract.Selection { + return r.substrate.Select().WithSubnetType(r.subnetType).WithStorage(r.storage).WithClaims(r.identities) +} + +// stamp is the selection's tags plus ownership and the display name. +func (r Descriptor) stamp() map[string]string { + tags := Filter(r.Selection()) + tags[Tag(contract.TagKeyOwner)] = r.ownership.String() + + // An adopted resource keeps the name it already had. + if !r.ownership.IsShared() { + tags[displayName] = r.Name() + } + + return tags +} + +// Tags is every tag this resource carries, before a casting merges +// CastingMeta.Labels() in alongside them. +func (r Descriptor) Tags() map[string]string { + return r.stamp() +} + +// Filter is the tag match that finds this resource. +func (r Descriptor) Filter() map[string]string { + return Filter(r.Selection()) +} + +// resourceType is the type token in a derived name and the ordered qualifiers +// that follow it. +type resourceType struct { + short string + qualifiers []qualifier +} + +var ( + typeCluster = resourceType{short: "cls"} + typeVPC = resourceType{short: "vpc"} + typeInternetGateway = resourceType{short: "igw"} + typeSubnet = resourceType{short: "sub", qualifiers: []qualifier{qualifierKey}} + typeRouteTable = resourceType{short: "rt", qualifiers: []qualifier{qualifierKey}} + typeNATGateway = resourceType{short: "nat", qualifiers: []qualifier{qualifierKey}} + typeElasticIP = resourceType{short: "eip", qualifiers: []qualifier{qualifierKey}} + typeSecurityGroup = resourceType{short: "sg", qualifiers: []qualifier{qualifierRole, qualifierPurpose}} + typeRole = resourceType{short: "iam", qualifiers: []qualifier{qualifierRole, qualifierPurpose}} + typeInstanceProfile = resourceType{short: "prf", qualifiers: []qualifier{qualifierRole}} + typeLaunchTemplate = resourceType{short: "lt", qualifiers: []qualifier{qualifierKey}} + typeAutoscalingGroup = resourceType{short: "asg", qualifiers: []qualifier{qualifierKey}} + typeNodeGroup = resourceType{short: "ng", qualifiers: []qualifier{qualifierKey}} + typeNode = resourceType{short: "node", qualifiers: []qualifier{qualifierKey, qualifierOrdinal}} + typeVolume = resourceType{short: "vol", qualifiers: []qualifier{qualifierKey, qualifierOrdinal}} +) + +func (resource resourceType) String() string { + return resource.short +} + +// qualifier renders one axis into a name segment. An empty string drops the +// segment, so a security group and its rules share one resource type. +type qualifier struct { + of func(Descriptor) string +} + +var ( + qualifierKey = qualifier{of: func(r Descriptor) string { return r.key.String() }} + qualifierRole = qualifier{of: func(r Descriptor) string { return r.role.String() }} + qualifierPurpose = qualifier{of: func(r Descriptor) string { return r.purpose.String() }} + + // Declared only by types that have an ordinal, since zero renders as "0". + qualifierOrdinal = qualifier{of: func(r Descriptor) string { return strconv.Itoa(r.ordinal) }} +) diff --git a/internal/contract/aws/resource_test.go b/internal/contract/aws/resource_test.go new file mode 100644 index 00000000..e1bcbf8a --- /dev/null +++ b/internal/contract/aws/resource_test.go @@ -0,0 +1,291 @@ +package aws + +import ( + "github.com/signoz/foundry/internal/contract" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResourceName(t *testing.T) { + substrate := contract.MustNewSubstrate("foundry") + privateA := contract.MustNewKey("private-a") + publicA := contract.MustNewKey("public-a") + persistent := contract.NewNodeGroup(contract.MustNewKey("persistent"), contract.StorageClassPersistent) + ephemeral := contract.NewNodeGroup(contract.MustNewKey("ephemeral"), contract.StorageClassEphemeral) + + tests := []struct { + name string + resource Descriptor + expectedName string + }{ + {name: "Cluster_Unqualified", resource: Cluster(substrate), expectedName: "foundry-cls"}, + {name: "VPC_Unqualified", resource: VPC(substrate), expectedName: "foundry-vpc"}, + {name: "InternetGateway_Unqualified", resource: InternetGateway(substrate), expectedName: "foundry-igw"}, + {name: "PrivateSubnet_Key", resource: Subnet(substrate, privateA, contract.SubnetTypePrivate), expectedName: "foundry-sub-private-a"}, + {name: "PublicSubnet_Key", resource: Subnet(substrate, publicA, contract.SubnetTypePublic), expectedName: "foundry-sub-public-a"}, + {name: "RouteTable_Key", resource: RouteTable(substrate, privateA), expectedName: "foundry-rt-private-a"}, + {name: "NATGateway_Key", resource: NATGateway(substrate, publicA), expectedName: "foundry-nat-public-a"}, + {name: "ElasticIP_Key", resource: ElasticIP(substrate, publicA), expectedName: "foundry-eip-public-a"}, + {name: "SecurityGroup_Role", resource: SecurityGroup(substrate, RoleTask), expectedName: "foundry-sg-task"}, + {name: "SecurityGroupRule_RoleAndPurpose", resource: SecurityGroupRule(substrate, RoleTask, contract.MustNewKey("intra-cluster")), expectedName: "foundry-sg-task-intra-cluster"}, + {name: "Role_Role", resource: IAMRole(substrate, RoleExec), expectedName: "foundry-iam-exec"}, + {name: "RolePolicy_RoleAndPurpose", resource: IAMRolePolicy(substrate, RoleTask, contract.MustNewKey("appconfig-read")), expectedName: "foundry-iam-task-appconfig-read"}, + {name: "InstanceProfile_Role", resource: InstanceProfile(substrate, RoleNode), expectedName: "foundry-prf-node"}, + {name: "LaunchTemplate_GroupKey", resource: LaunchTemplate(substrate, ephemeral), expectedName: "foundry-lt-ephemeral"}, + {name: "AutoscalingGroup_GroupKey", resource: AutoscalingGroup(substrate, ephemeral), expectedName: "foundry-asg-ephemeral"}, + {name: "Node_GroupKeyAndOrdinal", resource: Node(substrate, persistent, 0), expectedName: "foundry-node-persistent-0"}, + {name: "Volume_GroupKeyAndOrdinal", resource: Volume(substrate, persistent, 2), expectedName: "foundry-vol-persistent-2"}, + {name: "EphemeralNode_GroupKeyAndOrdinal", resource: Node(substrate, ephemeral, 1), expectedName: "foundry-node-ephemeral-1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedName, tt.resource.Name()) + }) + } +} + +// A role name is the longest suffix a caller has to budget for against its +// provider's cap, which this package does not know. +func TestRoleNameOverheadIsBounded(t *testing.T) { + purpose := contract.MustNewKey("appconfig-read") + maxRoleSuffix := len("-iam-exec-" + purpose.String()) + + for _, name := range []string{"a", "foundry", "signoz-prod-eu-central"} { + substrate := contract.MustNewSubstrate(name) + assert.LessOrEqual(t, len(IAMRolePolicy(substrate, RoleExec, purpose).Name())-len(name), maxRoleSuffix) + } +} + +// Adopting a resource must not rename it: the name belongs to whoever created it. +func TestSharedResourceKeepsItsName(t *testing.T) { + substrate := contract.MustNewSubstrate("foundry") + shared := VPC(substrate).WithOwnership(contract.OwnershipShared) + + assert.Equal(t, VPC(substrate).Name(), shared.Name()) + assert.NotContains(t, shared.Tags(), displayName) + assert.Equal(t, "shared", shared.Tags()[Tag(contract.TagKeyOwner)]) +} + +func TestResourceTags(t *testing.T) { + substrate := contract.MustNewSubstrate("foundry") + privateA := contract.MustNewKey("private-a") + persistent := contract.NewNodeGroup(contract.MustNewKey("persistent"), contract.StorageClassPersistent) + + tests := []struct { + name string + resource Descriptor + expectedPresent map[string]string + expectedAbsent []contract.TagKey + }{ + { + name: "Cluster_CarriesIdentityAndOwner", + resource: Cluster(substrate), + expectedPresent: map[string]string{ + Tag(contract.TagKeyName): "foundry", + Tag(contract.TagKeyOwner): "owned", + displayName: "foundry-cls", + }, + expectedAbsent: []contract.TagKey{contract.TagKeySubnetType, contract.TagKeyStorage, contract.TagKeyIdentities}, + }, + { + name: "PrivateSubnet_CarriesItsTypeSpelledOut", + resource: Subnet(substrate, privateA, contract.SubnetTypePrivate), + expectedPresent: map[string]string{ + displayName: "foundry-sub-private-a", + Tag(contract.TagKeySubnetType): "private", + }, + expectedAbsent: []contract.TagKey{contract.TagKeyStorage}, + }, + { + name: "PersistentNode_CarriesStorageFromItsGroup", + resource: Node(substrate, persistent, 0), + expectedPresent: map[string]string{ + displayName: "foundry-node-persistent-0", + Tag(contract.TagKeyStorage): "persistent", + }, + expectedAbsent: []contract.TagKey{contract.TagKeySubnetType, contract.TagKeyIdentities}, + }, + { + name: "ClaimedVolume_CarriesIdentities", + resource: Volume(substrate, persistent, 0).WithClaims(contract.Identities{ + contract.MustNewIdentity("telemetrystore", 0, 0), + contract.MustNewIdentity("metastore", 0), + }), + expectedPresent: map[string]string{ + displayName: "foundry-vol-persistent-0", + Tag(contract.TagKeyStorage): "persistent", + Tag(contract.TagKeyIdentities): "metastore-0,telemetrystore-0-0", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tags := tt.resource.Tags() + + for key, expected := range tt.expectedPresent { + assert.Equal(t, expected, tags[key], "tag %s", key) + } + + for _, key := range tt.expectedAbsent { + assert.NotContains(t, tags, Tag(key)) + } + }) + } +} + +func TestResourceFilter(t *testing.T) { + substrate := contract.MustNewSubstrate("foundry") + privateA := contract.MustNewKey("private-a") + persistent := contract.NewNodeGroup(contract.MustNewKey("persistent"), contract.StorageClassPersistent) + + tests := []struct { + name string + resource Descriptor + expectedFilter map[string]string + }{ + { + name: "VPC_SelectsIdentityOnly", + resource: VPC(substrate), + expectedFilter: map[string]string{ + Tag(contract.TagKeyName): "foundry", + }, + }, + { + name: "ProvenanceOnly_IsNotSelectedOn", + resource: Cluster(substrate), + expectedFilter: map[string]string{ + Tag(contract.TagKeyName): "foundry", + }, + }, + { + name: "PrivateSubnet_SelectsIdentityAndType", + resource: Subnet(substrate, privateA, contract.SubnetTypePrivate), + expectedFilter: map[string]string{ + Tag(contract.TagKeyName): "foundry", + Tag(contract.TagKeySubnetType): "private", + }, + }, + { + name: "PersistentNode_SelectsIdentityAndStorage", + resource: Node(substrate, persistent, 0), + expectedFilter: map[string]string{ + Tag(contract.TagKeyName): "foundry", + Tag(contract.TagKeyStorage): "persistent", + }, + }, + { + name: "ClaimedVolume_SelectsTheClaim", + resource: Volume(substrate, persistent, 0).WithClaims(contract.Identities{contract.MustNewIdentity("signoz", 0)}), + expectedFilter: map[string]string{ + Tag(contract.TagKeyName): "foundry", + Tag(contract.TagKeyStorage): "persistent", + Tag(contract.TagKeyIdentities): "signoz-0", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedFilter, tt.resource.Filter()) + }) + } +} + +// A fact stated once must render the same way wherever it is read back. The +// operator's key reaches the name; the closed enums reach the tags, which is +// all a consuming casting can predict. +func TestNameAndTagsAgreeOnTheSameFact(t *testing.T) { + substrate := contract.MustNewSubstrate("foundry") + + for _, subnetType := range []contract.SubnetType{contract.SubnetTypePrivate, contract.SubnetTypePublic} { + key := contract.MustNewKey(subnetType.String() + "-a") + subnet := Subnet(substrate, key, subnetType) + + assert.Contains(t, subnet.Name(), key.String()) + assert.Equal(t, subnetType.String(), subnet.Tags()[Tag(contract.TagKeySubnetType)]) + } + + for _, storage := range []contract.StorageClass{contract.StorageClassPersistent, contract.StorageClassEphemeral} { + group := contract.NewNodeGroup(contract.MustNewKey(storage.String()), storage) + node := Node(substrate, group, 0) + + assert.Contains(t, node.Name(), group.Key().String()) + assert.Equal(t, storage.String(), node.Tags()[Tag(contract.TagKeyStorage)]) + } +} + +// Two types sharing a short form would derive the same name shape. A security +// group and a role each cover two constructors, distinguished by the trailing +// purpose rather than by a second short form. +func TestResourceTypeShortFormsAreDistinct(t *testing.T) { + resourceTypes := []resourceType{ + typeCluster, typeVPC, typeInternetGateway, typeSubnet, typeRouteTable, + typeNATGateway, typeElasticIP, typeSecurityGroup, typeRole, + typeInstanceProfile, typeLaunchTemplate, typeAutoscalingGroup, + typeNode, typeVolume, + } + + seen := make(map[string]struct{}, len(resourceTypes)) + for _, resource := range resourceTypes { + assert.NotContains(t, seen, resource.String()) + seen[resource.String()] = struct{}{} + } +} + +// An empty qualifier drops its segment, so one declaration serves both a +// security group and the rules attached to it. +func TestUnsetQualifierDropsFromTheName(t *testing.T) { + substrate := contract.MustNewSubstrate("foundry") + + assert.Equal(t, "foundry-sg-task", SecurityGroup(substrate, RoleTask).Name()) + assert.Equal(t, "foundry-sg-task-intra-cluster", SecurityGroupRule(substrate, RoleTask, contract.MustNewKey("intra-cluster")).Name()) +} + +// A declared qualifier that renders nothing would silently drop a segment meant +// to distinguish the name. +func TestEveryDeclaredQualifierContributes(t *testing.T) { + substrate := contract.MustNewSubstrate("foundry") + privateA := contract.MustNewKey("private-a") + persistent := contract.NewNodeGroup(contract.MustNewKey("persistent"), contract.StorageClassPersistent) + + tests := []struct { + name string + resource Descriptor + expectedSegments int + }{ + {name: "VPC_NoQualifier", resource: VPC(substrate), expectedSegments: 0}, + {name: "Subnet_Key", resource: Subnet(substrate, privateA, contract.SubnetTypePrivate), expectedSegments: 1}, + {name: "NATGateway_Key", resource: NATGateway(substrate, privateA), expectedSegments: 1}, + {name: "Role_Role", resource: IAMRole(substrate, RoleExec), expectedSegments: 1}, + {name: "RolePolicy_RoleAndPurpose", resource: IAMRolePolicy(substrate, RoleExec, contract.MustNewKey("ssm-session")), expectedSegments: 2}, + {name: "InstanceProfile_Role", resource: InstanceProfile(substrate, RoleNode), expectedSegments: 1}, + {name: "LaunchTemplate_GroupKey", resource: LaunchTemplate(substrate, persistent), expectedSegments: 1}, + {name: "Node_GroupKeyAndOrdinal", resource: Node(substrate, persistent, 0), expectedSegments: 2}, + {name: "Volume_GroupKeyAndOrdinal", resource: Volume(substrate, persistent, 0), expectedSegments: 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rendered := 0 + for _, qualifier := range tt.resource.resourceType.qualifiers { + if qualifier.of(tt.resource) != "" { + rendered++ + } + } + + assert.Equal(t, tt.expectedSegments, rendered) + }) + } +} + +// Ordinal zero is a real ordinal, so only types that have one declare it. +func TestOrdinalZeroRenders(t *testing.T) { + substrate := contract.MustNewSubstrate("foundry") + persistent := contract.NewNodeGroup(contract.MustNewKey("persistent"), contract.StorageClassPersistent) + + assert.Equal(t, "foundry-node-persistent-0", Node(substrate, persistent, 0).Name()) + assert.Equal(t, "foundry-vpc", VPC(substrate).Name()) +} diff --git a/internal/contract/aws/resources.go b/internal/contract/aws/resources.go new file mode 100644 index 00000000..5bfd9d75 --- /dev/null +++ b/internal/contract/aws/resources.go @@ -0,0 +1,53 @@ +package aws + +// Resource is one provisioned thing: what to call it and what to stamp on it. +type Resource struct { + Name string `json:"name,omitempty"` + + Tags map[string]string `json:"tags,omitempty"` + + // Set when the resource is adopted rather than created, in which case the + // casting stamps nothing on it. + ID string `json:"id,omitempty"` +} + +// SubnetResource resolves a declared subnet. +type SubnetResource struct { + Name string `json:"name,omitempty"` + + Tags map[string]string `json:"tags,omitempty"` + + ID string `json:"id,omitempty"` + + Public bool `json:"public"` +} + +// NATGatewayResource is the egress path of one private subnet. +type NATGatewayResource struct { + Name string `json:"name,omitempty"` + + Tags map[string]string `json:"tags,omitempty"` + + ID string `json:"id,omitempty"` + + // The public subnet it sits in, in the same zone as the subnet it serves. + Subnet string `json:"subnet,omitempty"` + + Address *Resource `json:"address,omitempty"` +} + +// NetworkResources is the network every substrate shares, embedded by each +// mode beside what only that mode provisions. +type NetworkResources struct { + VPC Resource `json:"vpc,omitzero"` + + InternetGateway Resource `json:"internetGateway,omitzero"` + + // Keyed by the subnet reference. The maps below are not parallel to this + // one: a public subnet has no NAT gateway, and neither has an adopted one. + Subnets map[string]SubnetResource `json:"subnets,omitempty"` + + RouteTables map[string]Resource `json:"routeTables,omitempty"` + + NATGateways map[string]NATGatewayResource `json:"natGateways,omitempty"` +} diff --git a/internal/contract/aws/role.go b/internal/contract/aws/role.go new file mode 100644 index 00000000..299c440c --- /dev/null +++ b/internal/contract/aws/role.go @@ -0,0 +1,19 @@ +package aws + +// Role is what a security group or an IAM role is attached to. Security groups +// use node and task; the rest are IAM only. +type Role struct { + s string +} + +var ( + RoleNode = Role{s: "node"} + RoleTask = Role{s: "task"} + RoleExec = Role{s: "exec"} + RoleCluster = Role{s: "cluster"} + RoleEBSCSI = Role{s: "ebs-csi"} +) + +func (role Role) String() string { + return role.s +} diff --git a/internal/contract/aws/role_test.go b/internal/contract/aws/role_test.go new file mode 100644 index 00000000..c76dd36a --- /dev/null +++ b/internal/contract/aws/role_test.go @@ -0,0 +1,36 @@ +package aws + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRole(t *testing.T) { + tests := []struct { + name string + role Role + expectedWord string + }{ + {name: "Node_Rendered", role: RoleNode, expectedWord: "node"}, + {name: "Task_Rendered", role: RoleTask, expectedWord: "task"}, + {name: "Exec_Rendered", role: RoleExec, expectedWord: "exec"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedWord, tt.role.String()) + }) + } +} + +// Two roles sharing a rendering would collide in a derived name. +func TestRolesAreDistinct(t *testing.T) { + roles := []Role{RoleNode, RoleTask, RoleExec} + + seen := make(map[string]struct{}, len(roles)) + for _, role := range roles { + assert.NotContains(t, seen, role.String()) + seen[role.String()] = struct{}{} + } +} diff --git a/internal/contract/aws/tag.go b/internal/contract/aws/tag.go new file mode 100644 index 00000000..f7c53fc8 --- /dev/null +++ b/internal/contract/aws/tag.go @@ -0,0 +1,26 @@ +package aws + +import ( + "github.com/signoz/foundry/internal/contract" + "github.com/signoz/foundry/internal/domain" +) + +// displayName is the unprefixed tag an AWS console shows as a resource's name. +const displayName = "Name" + +// Tag renders a fact as an AWS tag key, which accepts the full prefix. +func Tag(key contract.TagKey) string { + return domain.MetadataPrefix + key.String() +} + +// Filter renders a selection as the tag match a data source is keyed by. +func Filter(selection contract.Selection) map[string]string { + match := selection.Match() + + tags := make(map[string]string, len(match)) + for key, value := range match { + tags[Tag(key)] = value + } + + return tags +} diff --git a/internal/contract/aws/tag_test.go b/internal/contract/aws/tag_test.go new file mode 100644 index 00000000..4058ae49 --- /dev/null +++ b/internal/contract/aws/tag_test.go @@ -0,0 +1,32 @@ +package aws + +import ( + "testing" + + "github.com/signoz/foundry/internal/contract" + "github.com/stretchr/testify/assert" +) + +// A filter's keys are the only ones whose spelling live infrastructure depends +// on: renaming one leaves it unmatched, with no checkpoint to catch it. AWS is +// the only provider whose tag keys accept this prefix, which is why the +// spelling is asserted here and not beside the facts. +func TestFilterKeysMatchDeployedSpelling(t *testing.T) { + filter := Filter(contract.MustNewSubstrate("foundry").Select(). + WithSubnetType(contract.SubnetTypePrivate). + WithStorage(contract.StorageClassPersistent). + WithClaims(contract.Identities{contract.MustNewIdentity("signoz", 0)})) + + assert.Equal(t, map[string]string{ + "foundry.signoz.io/name": "foundry", + "foundry.signoz.io/subnet-type": "private", + "foundry.signoz.io/storage": "persistent", + "foundry.signoz.io/identities": "signoz-0", + }, filter) +} + +// The display tag is the provider's own, not foundry's, so it carries no prefix. +func TestDisplayNameIsProviderNative(t *testing.T) { + assert.Equal(t, "Name", displayName) + assert.Equal(t, "foundry.signoz.io/owner", Tag(contract.TagKeyOwner)) +} diff --git a/internal/contract/identity.go b/internal/contract/identity.go new file mode 100644 index 00000000..1a561635 --- /dev/null +++ b/internal/contract/identity.go @@ -0,0 +1,121 @@ +package contract + +import ( + "slices" + "strconv" + "strings" + + "github.com/signoz/foundry/internal/errors" +) + +const identitySeparator = "," + +// Identity is a component and its ordinals, "telemetrystore-0-0". It claims a +// volume, which is what keeps the component's data across a node replacement. +type Identity struct { + s string +} + +func NewIdentity(component string, ordinals ...int) (Identity, error) { + if component == "" { + return Identity{}, errors.Newf(errors.TypeInvalidInput, "failed to create identity: component is empty") + } + + // A separator inside a component would split into two on the way back. + if strings.Contains(component, identitySeparator) { + return Identity{}, errors.Newf(errors.TypeInvalidInput, "failed to create identity from %q: component contains %q", component, identitySeparator) + } + + parts := make([]string, 0, len(ordinals)+1) + parts = append(parts, component) + + for _, ordinal := range ordinals { + if ordinal < 0 { + return Identity{}, errors.Newf(errors.TypeInvalidInput, "failed to create identity from %q: ordinal %d is negative", component, ordinal) + } + + parts = append(parts, strconv.Itoa(ordinal)) + } + + return Identity{s: strings.Join(parts, "-")}, nil +} + +func MustNewIdentity(component string, ordinals ...int) Identity { + identity, err := NewIdentity(component, ordinals...) + if err != nil { + panic(err) + } + + return identity +} + +// ParseIdentity splits a claimed identity: trailing numeric segments are the +// ordinals, the rest is the component, which may itself be hyphenated. +func ParseIdentity(value string) (Identity, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return Identity{}, errors.Newf(errors.TypeInvalidInput, "failed to create identity from %q: identity is empty", value) + } + + segments := strings.Split(trimmed, "-") + + ordinals := make([]int, 0, len(segments)) + boundary := len(segments) + + for boundary > 1 { + ordinal, err := strconv.Atoi(segments[boundary-1]) + if err != nil { + break + } + + ordinals = append([]int{ordinal}, ordinals...) + boundary-- + } + + return NewIdentity(strings.Join(segments[:boundary], "-"), ordinals...) +} + +func (identity Identity) String() string { + return identity.s +} + +// Identities is the set of claims one volume carries, comma-separated and +// sorted so that an unchanged set produces no diff. GCP labels reject the +// comma. +type Identities []Identity + +func (identities Identities) String() string { + parts := make([]string, 0, len(identities)) + for _, identity := range identities.sorted() { + parts = append(parts, identity.s) + } + + return strings.Join(parts, identitySeparator) +} + +func ParseIdentities(value string) (Identities, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + + parts := strings.Split(value, identitySeparator) + + identities := make(Identities, 0, len(parts)) + for _, part := range parts { + identity, err := ParseIdentity(part) + if err != nil { + return nil, errors.Wrapf(err, errors.TypeInvalidInput, "failed to create identities from %q", value) + } + + identities = append(identities, identity) + } + + return identities.sorted(), nil +} + +func (identities Identities) sorted() Identities { + out := slices.Clone(identities) + slices.SortFunc(out, func(a, b Identity) int { return strings.Compare(a.s, b.s) }) + + return out +} diff --git a/internal/contract/identity_test.go b/internal/contract/identity_test.go new file mode 100644 index 00000000..f0e3f6c3 --- /dev/null +++ b/internal/contract/identity_test.go @@ -0,0 +1,186 @@ +package contract + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewIdentity(t *testing.T) { + tests := []struct { + name string + component string + ordinals []int + pass bool + expectedIdentity string + }{ + {name: "ShardAndReplica_Valid", component: "telemetrystore", ordinals: []int{0, 0}, pass: true, expectedIdentity: "telemetrystore-0-0"}, + {name: "SingleOrdinal_Valid", component: "signoz", ordinals: []int{0}, pass: true, expectedIdentity: "signoz-0"}, + {name: "NoOrdinal_Valid", component: "metastore", pass: true, expectedIdentity: "metastore"}, + {name: "Empty_Invalid", component: "", ordinals: []int{0}, pass: false}, + {name: "ComponentWithSeparator_Invalid", component: "telemetry,store", ordinals: []int{0}, pass: false}, + {name: "NegativeOrdinal_Invalid", component: "signoz", ordinals: []int{-1}, pass: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + identity, err := NewIdentity(tt.component, tt.ordinals...) + if !tt.pass { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expectedIdentity, identity.String()) + }) + } +} + +func TestParseIdentity(t *testing.T) { + tests := []struct { + name string + value string + pass bool + expectedIdentity Identity + }{ + {name: "ShardAndReplica_Valid", value: "telemetrystore-0-0", pass: true, expectedIdentity: MustNewIdentity("telemetrystore", 0, 0)}, + {name: "SingleOrdinal_Valid", value: "signoz-0", pass: true, expectedIdentity: MustNewIdentity("signoz", 0)}, + {name: "NoOrdinal_Valid", value: "metastore", pass: true, expectedIdentity: MustNewIdentity("metastore")}, + {name: "HyphenatedComponent_Valid", value: "store-pool-1-2", pass: true, expectedIdentity: MustNewIdentity("store-pool", 1, 2)}, + {name: "Spaced_Trimmed", value: " keeper-1 ", pass: true, expectedIdentity: MustNewIdentity("keeper", 1)}, + {name: "Empty_Invalid", value: "", pass: false}, + {name: "Blank_Invalid", value: " ", pass: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + identity, err := ParseIdentity(tt.value) + if !tt.pass { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expectedIdentity, identity) + }) + } +} + +// Parsing validates through NewIdentity, so a value the encoder could not have +// produced is rejected. +func TestParseIdentityDelegatesValidation(t *testing.T) { + _, direct := NewIdentity("telemetry,store", 0) + _, parsed := ParseIdentity("telemetry,store-0") + + assert.Error(t, direct) + assert.Error(t, parsed) +} + +func TestIdentitiesString(t *testing.T) { + tests := []struct { + name string + identities Identities + expectedValue string + }{ + { + name: "Empty_RendersEmpty", + identities: Identities{}, + expectedValue: "", + }, + { + name: "Single_RendersBare", + identities: Identities{MustNewIdentity("signoz", 0)}, + expectedValue: "signoz-0", + }, + { + name: "Several_JoinsSorted", + identities: Identities{ + MustNewIdentity("telemetrystore", 0, 1), + MustNewIdentity("metastore", 0), + MustNewIdentity("telemetrystore", 0, 0), + }, + expectedValue: "metastore-0,telemetrystore-0-0,telemetrystore-0-1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedValue, tt.identities.String()) + }) + } +} + +// The same claims in a different order must render identically, or every plan +// shows a tag diff. +func TestIdentitiesRenderIndependentOfOrder(t *testing.T) { + forward := Identities{MustNewIdentity("keeper", 0), MustNewIdentity("keeper", 1), MustNewIdentity("keeper", 2)} + reversed := Identities{forward[2], forward[1], forward[0]} + + assert.Equal(t, forward.String(), reversed.String()) +} + +func TestParseIdentities(t *testing.T) { + tests := []struct { + name string + value string + pass bool + expectedIdentities Identities + }{ + { + name: "Empty_YieldsNone", + value: "", + pass: true, + expectedIdentities: nil, + }, + { + name: "Single_YieldsOne", + value: "signoz-0", + pass: true, + expectedIdentities: Identities{MustNewIdentity("signoz", 0)}, + }, + { + name: "Several_YieldsSorted", + value: "telemetrystore-0-1,metastore-0", + pass: true, + expectedIdentities: Identities{MustNewIdentity("metastore", 0), MustNewIdentity("telemetrystore", 0, 1)}, + }, + { + name: "Spaced_TrimsEntries", + value: "keeper-0, keeper-1", + pass: true, + expectedIdentities: Identities{MustNewIdentity("keeper", 0), MustNewIdentity("keeper", 1)}, + }, + {name: "TrailingSeparator_Invalid", value: "keeper-0,", pass: false}, + {name: "DoubledSeparator_Invalid", value: "keeper-0,,keeper-1", pass: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + identities, err := ParseIdentities(tt.value) + if !tt.pass { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expectedIdentities, identities) + }) + } +} + +// Terraform reads this value back with split(), so the encoding has to +// round-trip. +func TestIdentitiesRoundTrip(t *testing.T) { + identities := Identities{ + MustNewIdentity("telemetrykeeper", 0), + MustNewIdentity("telemetrystore", 0, 0), + MustNewIdentity("metastore", 0), + MustNewIdentity("signoz", 0), + } + + parsed, err := ParseIdentities(identities.String()) + + assert.NoError(t, err) + assert.Equal(t, identities.String(), parsed.String()) + assert.Len(t, parsed, len(identities)) +} diff --git a/internal/contract/key.go b/internal/contract/key.go new file mode 100644 index 00000000..5b4057c1 --- /dev/null +++ b/internal/contract/key.go @@ -0,0 +1,37 @@ +package contract + +import ( + "github.com/signoz/foundry/internal/errors" +) + +// Key is the operator-chosen reference for one declared subnet or instance +// group, lowercase alphanumeric with interior hyphens. It is the qualifier in +// every name derived for that thing. +type Key struct { + s string +} + +func NewKey(key string) (Key, error) { + if key == "" { + return Key{}, errors.Newf(errors.TypeInvalidInput, "failed to create key from %q: key is empty", key) + } + + if !namePattern.MatchString(key) { + return Key{}, errors.Newf(errors.TypeInvalidInput, "failed to create key from %q: key is not lowercase alphanumeric with interior hyphens", key) + } + + return Key{s: key}, nil +} + +func MustNewKey(key string) Key { + parsed, err := NewKey(key) + if err != nil { + panic(err) + } + + return parsed +} + +func (key Key) String() string { + return key.s +} diff --git a/internal/contract/key_test.go b/internal/contract/key_test.go new file mode 100644 index 00000000..cbbd7040 --- /dev/null +++ b/internal/contract/key_test.go @@ -0,0 +1,50 @@ +package contract + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewKey(t *testing.T) { + tests := []struct { + name string + input string + pass bool + expectedKey string + }{ + {name: "Word_Valid", input: "persistent", pass: true, expectedKey: "persistent"}, + {name: "InteriorHyphens_Valid", input: "private-us-east-1a", pass: true, expectedKey: "private-us-east-1a"}, + {name: "Digits_Valid", input: "pool2", pass: true, expectedKey: "pool2"}, + {name: "Empty_Invalid", input: "", pass: false}, + {name: "Uppercase_Invalid", input: "Private", pass: false}, + {name: "LeadingHyphen_Invalid", input: "-private", pass: false}, + {name: "TrailingHyphen_Invalid", input: "private-", pass: false}, + {name: "Underscore_Invalid", input: "private_a", pass: false}, + {name: "Dot_Invalid", input: "private.a", pass: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key, err := NewKey(tt.input) + if !tt.pass { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expectedKey, key.String()) + }) + } +} + +// A key reaches a derived name unchanged, so what it accepts is what a name +// segment may contain. +func TestKeyAcceptsWhatASubstrateNameDoes(t *testing.T) { + for _, name := range []string{"foundry", "signoz-prod-eu", "signoz2"} { + key, err := NewKey(name) + + assert.NoError(t, err) + assert.Equal(t, name, key.String()) + } +} diff --git a/internal/contract/node_group.go b/internal/contract/node_group.go new file mode 100644 index 00000000..f161eb5a --- /dev/null +++ b/internal/contract/node_group.go @@ -0,0 +1,20 @@ +package contract + +// NodeGroup is a pool of interchangeable nodes, named by its key and selected +// by its storage class. +type NodeGroup struct { + key Key + storage StorageClass +} + +func NewNodeGroup(key Key, storage StorageClass) NodeGroup { + return NodeGroup{key: key, storage: storage} +} + +func (group NodeGroup) Key() Key { + return group.key +} + +func (group NodeGroup) Storage() StorageClass { + return group.storage +} diff --git a/internal/contract/node_group_test.go b/internal/contract/node_group_test.go new file mode 100644 index 00000000..4f686b3e --- /dev/null +++ b/internal/contract/node_group_test.go @@ -0,0 +1,24 @@ +package contract + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// The key names the group and the class selects it: two groups may share a +// class, and a consumer that filters on the class reaches both. +func TestNodeGroup(t *testing.T) { + hot := NewNodeGroup(MustNewKey("hot"), StorageClassPersistent) + cold := NewNodeGroup(MustNewKey("cold"), StorageClassPersistent) + + assert.Equal(t, "hot", hot.Key().String()) + assert.Equal(t, "cold", cold.Key().String()) + assert.Equal(t, hot.Storage(), cold.Storage()) + + filter := MustNewSubstrate("foundry").Select().WithStorage(StorageClassPersistent).Match() + assert.Equal(t, map[TagKey]string{ + TagKeyName: "foundry", + TagKeyStorage: "persistent", + }, filter) +} diff --git a/internal/contract/ownership.go b/internal/contract/ownership.go new file mode 100644 index 00000000..3f30c13e --- /dev/null +++ b/internal/contract/ownership.go @@ -0,0 +1,25 @@ +package contract + +// Ownership is whether the substrate created a resource or adopted an existing +// one. The zero value is owned. +type Ownership struct { + s string + shared bool +} + +var ( + OwnershipOwned = Ownership{s: "owned"} + OwnershipShared = Ownership{s: "shared", shared: true} +) + +func (ownership Ownership) String() string { + if ownership.s == "" { + return OwnershipOwned.s + } + + return ownership.s +} + +func (ownership Ownership) IsShared() bool { + return ownership.shared +} diff --git a/internal/contract/ownership_test.go b/internal/contract/ownership_test.go new file mode 100644 index 00000000..9f25fa99 --- /dev/null +++ b/internal/contract/ownership_test.go @@ -0,0 +1,31 @@ +package contract + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOwnership(t *testing.T) { + tests := []struct { + name string + ownership Ownership + expectedWord string + expectedShared bool + }{ + {name: "Owned_NotShared", ownership: OwnershipOwned, expectedWord: "owned", expectedShared: false}, + {name: "Shared_IsShared", ownership: OwnershipShared, expectedWord: "shared", expectedShared: true}, + + // A caller says nothing when the substrate created the resource itself, + // which is the common case, so the zero value has to mean owned in both + // renderings rather than only in one. + {name: "ZeroValue_OwnedInBothForms", ownership: Ownership{}, expectedWord: "owned", expectedShared: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedWord, tt.ownership.String()) + assert.Equal(t, tt.expectedShared, tt.ownership.IsShared()) + }) + } +} diff --git a/internal/contract/selection.go b/internal/contract/selection.go new file mode 100644 index 00000000..d77790c7 --- /dev/null +++ b/internal/contract/selection.go @@ -0,0 +1,53 @@ +package contract + +// Selection is a substrate narrowed by the facts a consumer can predict: the +// subnet type, the storage class, and the identities claiming a resource. +type Selection struct { + substrate Substrate + subnetType SubnetType + storage StorageClass + identities Identities +} + +func (s Substrate) Select() Selection { + return Selection{substrate: s} +} + +func (selection Selection) WithSubnetType(subnetType SubnetType) Selection { + selection.subnetType = subnetType + + return selection +} + +func (selection Selection) WithStorage(storage StorageClass) Selection { + selection.storage = storage + + return selection +} + +func (selection Selection) WithClaims(identities Identities) Selection { + selection.identities = identities + + return selection +} + +// Match is the tag set that finds exactly what the selection narrows to. +func (selection Selection) Match() map[TagKey]string { + tags := map[TagKey]string{ + TagKeyName: selection.substrate.name, + } + + if selection.subnetType != (SubnetType{}) { + tags[TagKeySubnetType] = selection.subnetType.String() + } + + if selection.storage != (StorageClass{}) { + tags[TagKeyStorage] = selection.storage.String() + } + + if len(selection.identities) > 0 { + tags[TagKeyIdentities] = selection.identities.String() + } + + return tags +} diff --git a/internal/contract/selection_test.go b/internal/contract/selection_test.go new file mode 100644 index 00000000..bb11629d --- /dev/null +++ b/internal/contract/selection_test.go @@ -0,0 +1,66 @@ +package contract + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSelectionFilter(t *testing.T) { + substrate := MustNewSubstrate("foundry") + + tests := []struct { + name string + selection Selection + expectedMatch map[TagKey]string + }{ + { + name: "Substrate_MatchesEverythingItOwns", + selection: substrate.Select(), + expectedMatch: map[TagKey]string{ + TagKeyName: "foundry", + }, + }, + { + name: "PrivateSubnet_MatchesTheType", + selection: substrate.Select().WithSubnetType(SubnetTypePrivate), + expectedMatch: map[TagKey]string{ + TagKeyName: "foundry", + TagKeySubnetType: "private", + }, + }, + { + name: "PersistentClass_MatchesTheClass", + selection: substrate.Select().WithStorage(StorageClassPersistent), + expectedMatch: map[TagKey]string{ + TagKeyName: "foundry", + TagKeyStorage: "persistent", + }, + }, + { + name: "EphemeralClass_MatchesTheClass", + selection: substrate.Select().WithStorage(StorageClassEphemeral), + expectedMatch: map[TagKey]string{ + TagKeyName: "foundry", + TagKeyStorage: "ephemeral", + }, + }, + { + name: "Claim_MatchesTheHolder", + selection: substrate.Select(). + WithStorage(StorageClassPersistent). + WithClaims(Identities{MustNewIdentity("telemetrystore", 0, 0)}), + expectedMatch: map[TagKey]string{ + TagKeyName: "foundry", + TagKeyStorage: "persistent", + TagKeyIdentities: "telemetrystore-0-0", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedMatch, tt.selection.Match()) + }) + } +} diff --git a/internal/contract/storage_class.go b/internal/contract/storage_class.go new file mode 100644 index 00000000..5ef1c08e --- /dev/null +++ b/internal/contract/storage_class.go @@ -0,0 +1,50 @@ +package contract + +import "github.com/signoz/foundry/internal/errors" + +var ( + // StorageClassPersistent nodes each carry a volume that outlives them and is + // claimed by an identity, so the group is pinned. + StorageClassPersistent = StorageClass{s: "persistent", data: true, pinned: true} + + // StorageClassEphemeral nodes are interchangeable and keep nothing. + StorageClassEphemeral = StorageClass{s: "ephemeral"} +) + +// StorageClass is the durability of a node group's storage, and carries what +// that implies for the group's volumes and bounds. +type StorageClass struct { + s string + data bool + pinned bool +} + +func ParseStorageClass(value string) (StorageClass, error) { + for _, class := range StorageClasses() { + if class.String() == value { + return class, nil + } + } + + return StorageClass{}, errors.Newf(errors.TypeInvalidInput, "failed to create storage class from %q: it names no class", value) +} + +func (class StorageClass) String() string { + return class.s +} + +// RequiresDataVolume reports whether nodes of this class must declare a volume +// that outlives them. Other classes must not. +func (class StorageClass) RequiresDataVolume() bool { + return class.data +} + +// IsPinned reports whether the group's size is fixed, meaning minSize and +// maxSize must be equal. +func (class StorageClass) IsPinned() bool { + return class.pinned +} + +func StorageClasses() []StorageClass { + return []StorageClass{StorageClassPersistent, StorageClassEphemeral} +} diff --git a/internal/contract/storage_class_test.go b/internal/contract/storage_class_test.go new file mode 100644 index 00000000..815e6933 --- /dev/null +++ b/internal/contract/storage_class_test.go @@ -0,0 +1,39 @@ +package contract + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseStorageClass(t *testing.T) { + tests := []struct { + name string + value string + pass bool + expectedPinned bool + expectedDataVol bool + expectedSpelling string + }{ + {name: "Persistent_Valid", value: "persistent", pass: true, expectedPinned: true, expectedDataVol: true, expectedSpelling: "persistent"}, + {name: "Ephemeral_Valid", value: "ephemeral", pass: true, expectedSpelling: "ephemeral"}, + {name: "Empty_Invalid", value: "", pass: false}, + {name: "Unknown_Invalid", value: "durable", pass: false}, + {name: "WrongCase_Invalid", value: "Persistent", pass: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + class, err := ParseStorageClass(tt.value) + if !tt.pass { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expectedSpelling, class.String()) + assert.Equal(t, tt.expectedPinned, class.IsPinned()) + assert.Equal(t, tt.expectedDataVol, class.RequiresDataVolume()) + }) + } +} diff --git a/internal/contract/subnet_type.go b/internal/contract/subnet_type.go new file mode 100644 index 00000000..d37f851c --- /dev/null +++ b/internal/contract/subnet_type.go @@ -0,0 +1,43 @@ +package contract + +import "github.com/signoz/foundry/internal/errors" + +var ( + // SubnetTypePrivate subnets have no route to an internet gateway. Workloads + // are placed here, and reach out through a NAT gateway. + SubnetTypePrivate = SubnetType{s: "private"} + + // SubnetTypePublic subnets route to an internet gateway and hold the NAT + // gateways serving the private ones. + SubnetTypePublic = SubnetType{s: "public", public: true} +) + +// SubnetType is whether a subnet faces the internet. +type SubnetType struct { + s string + public bool +} + +func ParseSubnetType(value string) (SubnetType, error) { + for _, subnetType := range SubnetTypes() { + if subnetType.String() == value { + return subnetType, nil + } + } + + return SubnetType{}, errors.Newf(errors.TypeInvalidInput, "failed to create subnet type from %q: it names no type", value) +} + +func (subnetType SubnetType) String() string { + return subnetType.s +} + +// IsPublic reports whether the subnet routes to an internet gateway, and so +// whether a NAT gateway may be placed in it. +func (subnetType SubnetType) IsPublic() bool { + return subnetType.public +} + +func SubnetTypes() []SubnetType { + return []SubnetType{SubnetTypePrivate, SubnetTypePublic} +} diff --git a/internal/contract/subnet_type_test.go b/internal/contract/subnet_type_test.go new file mode 100644 index 00000000..83c2d40b --- /dev/null +++ b/internal/contract/subnet_type_test.go @@ -0,0 +1,36 @@ +package contract + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseSubnetType(t *testing.T) { + tests := []struct { + name string + value string + pass bool + expectedPublic bool + expectedSpelling string + }{ + {name: "Private_Valid", value: "private", pass: true, expectedSpelling: "private"}, + {name: "Public_Valid", value: "public", pass: true, expectedPublic: true, expectedSpelling: "public"}, + {name: "Empty_Invalid", value: "", pass: false}, + {name: "Unknown_Invalid", value: "internal", pass: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + subnetType, err := ParseSubnetType(tt.value) + if !tt.pass { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expectedSpelling, subnetType.String()) + assert.Equal(t, tt.expectedPublic, subnetType.IsPublic()) + }) + } +} diff --git a/internal/contract/substrate.go b/internal/contract/substrate.go new file mode 100644 index 00000000..47b6d867 --- /dev/null +++ b/internal/contract/substrate.go @@ -0,0 +1,54 @@ +// Package contract derives the names and tags a provisioned substrate is +// identified by, so that the casting which provisions it and the casting which +// consumes it arrive at the same values without reading the platform. +// +// Provider name-length caps are not enforced here. +package contract + +import ( + "regexp" + + "github.com/signoz/foundry/internal/errors" +) + +// namePattern is what the strictest provider accepts as a name segment. +var namePattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) + +// maxNameLength matches the metadata.name cap in the casting schema. +const maxNameLength = 63 + +// Substrate is the infrastructure an installation runs on, named by the +// provisioning casting's metadata.name. Every derived name and tag comes from +// that name alone. +type Substrate struct { + name string +} + +func NewSubstrate(name string) (Substrate, error) { + if name == "" { + return Substrate{}, errors.Newf(errors.TypeInvalidInput, "failed to create substrate from %q: name is empty", name) + } + + if len(name) > maxNameLength { + return Substrate{}, errors.Newf(errors.TypeInvalidInput, "failed to create substrate from %q: name is longer than %d characters", name, maxNameLength) + } + + if !namePattern.MatchString(name) { + return Substrate{}, errors.Newf(errors.TypeInvalidInput, "failed to create substrate from %q: name is not lowercase alphanumeric with interior hyphens", name) + } + + return Substrate{name: name}, nil +} + +func MustNewSubstrate(name string) Substrate { + substrate, err := NewSubstrate(name) + if err != nil { + panic(err) + } + + return substrate +} + +func (s Substrate) String() string { + return s.name +} diff --git a/internal/contract/substrate_test.go b/internal/contract/substrate_test.go new file mode 100644 index 00000000..f32a6e47 --- /dev/null +++ b/internal/contract/substrate_test.go @@ -0,0 +1,40 @@ +package contract + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewSubstrate(t *testing.T) { + tests := []struct { + name string + input string + pass bool + expectedName string + }{ + {name: "Lowercase_Valid", input: "foundry", pass: true, expectedName: "foundry"}, + {name: "InteriorHyphens_Valid", input: "signoz-prod-eu", pass: true, expectedName: "signoz-prod-eu"}, + {name: "Digits_Valid", input: "signoz2", pass: true, expectedName: "signoz2"}, + {name: "Empty_Invalid", input: "", pass: false}, + {name: "Uppercase_Invalid", input: "Foundry", pass: false}, + {name: "LeadingHyphen_Invalid", input: "-foundry", pass: false}, + {name: "TrailingHyphen_Invalid", input: "foundry-", pass: false}, + {name: "Underscore_Invalid", input: "foundry_prod", pass: false}, + {name: "TooLong_Invalid", input: strings.Repeat("a", maxNameLength+1), pass: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + substrate, err := NewSubstrate(tt.input) + if !tt.pass { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expectedName, substrate.String()) + }) + } +} diff --git a/internal/contract/tag.go b/internal/contract/tag.go new file mode 100644 index 00000000..49a51634 --- /dev/null +++ b/internal/contract/tag.go @@ -0,0 +1,20 @@ +package contract + +// TagKey is one fact a substrate records, without a provider's prefix or +// grammar. A GCP label key rejects the dot and the slash, an Azure tag name +// rejects the slash, so each provider renders the key its own way. +type TagKey struct { + s string +} + +var ( + TagKeyName = TagKey{s: "name"} + TagKeyStorage = TagKey{s: "storage"} + TagKeyIdentities = TagKey{s: "identities"} + TagKeyOwner = TagKey{s: "owner"} + TagKeySubnetType = TagKey{s: "subnet-type"} +) + +func (tagKey TagKey) String() string { + return tagKey.s +} diff --git a/internal/contract/tag_test.go b/internal/contract/tag_test.go new file mode 100644 index 00000000..a8f7a811 --- /dev/null +++ b/internal/contract/tag_test.go @@ -0,0 +1,46 @@ +package contract + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// A fact is unqualified: the provider that stamps it decides the spelling, so +// nothing here may carry a prefix a provider's grammar could reject. +func TestTagKeys(t *testing.T) { + tests := []struct { + name string + tagKey TagKey + expectedKey string + }{ + {name: "Name_Unqualified", tagKey: TagKeyName, expectedKey: "name"}, + {name: "Storage_Unqualified", tagKey: TagKeyStorage, expectedKey: "storage"}, + {name: "Identities_Unqualified", tagKey: TagKeyIdentities, expectedKey: "identities"}, + {name: "Owner_Unqualified", tagKey: TagKeyOwner, expectedKey: "owner"}, + {name: "SubnetType_Unqualified", tagKey: TagKeySubnetType, expectedKey: "subnet-type"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedKey, tt.tagKey.String()) + assert.NotContains(t, tt.tagKey.String(), "/") + assert.NotContains(t, tt.tagKey.String(), ".") + }) + } +} + +// Two facts sharing a name would collapse into one tag whichever way a +// provider spells them. +func TestTagKeysAreDistinct(t *testing.T) { + tagKeys := []TagKey{ + TagKeyName, TagKeyStorage, TagKeyIdentities, + TagKeyOwner, TagKeySubnetType, + } + + seen := make(map[string]struct{}, len(tagKeys)) + for _, tagKey := range tagKeys { + assert.NotContains(t, seen, tagKey.String()) + seen[tagKey.String()] = struct{}{} + } +} diff --git a/internal/domain/metadata.go b/internal/domain/metadata.go new file mode 100644 index 00000000..7fa8c8ad --- /dev/null +++ b/internal/domain/metadata.go @@ -0,0 +1,11 @@ +package domain + +// MetadataPrefix namespaces every key foundry stamps onto, or reads from, +// something it generates: labels on a workload, annotations a user writes, tags +// on a cloud resource. Declaring it once is what keeps the three families in one +// namespace even though nothing compares them. +// +// Keys are one segment deep by convention, foundry.signoz.io/managed-by rather +// than foundry.signoz.io/ecs/cluster-id, so the namespace stays flat and +// greppable. +const MetadataPrefix = "foundry.signoz.io/" diff --git a/internal/foundry/forge.go b/internal/foundry/forge.go index 54270cd0..81dc4823 100644 --- a/internal/foundry/forge.go +++ b/internal/foundry/forge.go @@ -3,11 +3,8 @@ package foundry import ( "context" "log/slog" - "path/filepath" "github.com/signoz/foundry/api/v1alpha1" - "github.com/signoz/foundry/api/v1alpha1/installation" - "github.com/signoz/foundry/internal/domain" foundryerrors "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/writer" ) @@ -52,37 +49,13 @@ func (foundry *Foundry) Forge(ctx context.Context, machinery v1alpha1.Machinery, } } - // Generate infrastructure-as-code manifests if enabled, before writing the lock file - // so that the generated file contents are captured in the lock's infrastructure.status. - // Gated to installation.Casting - var infraMaterials []domain.Material - if config, ok := machinery.(*installation.Casting); ok && config.Spec.Infrastructure.Enabled { - spec := &config.Spec - foundry.Logger.InfoContext(ctx, "generating infrastructure manifests", - slog.String("casting.metadata.name", config.Metadata.Name), - slog.String("deployment.platform", spec.Deployment.Platform.String())) - - infraMaterials, err = foundry.InfrastructureGenerator.Generate(ctx, *config) - if err != nil { - return foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to generate infrastructure manifests") - } - - // Populate infrastructure status with generated file contents keyed by filename. - if len(infraMaterials) > 0 { - spec.Infrastructure.Status = make(map[string]string, len(infraMaterials)) - for _, m := range infraMaterials { - spec.Infrastructure.Status[filepath.Base(m.Path())] = string(m.FmtContents()) - } - } - } - - // writing the merged config (including infrastructure status) to the lock file + // writing the merged config to the lock file foundry.Logger.InfoContext(ctx, "writing lock file") if err := foundry.Config.CreateV1Alpha1Lock(ctx, p.Machinery(), path); err != nil { return err } - if len(materials) == 0 && len(infraMaterials) == 0 { + if len(materials) == 0 { foundry.Logger.WarnContext(ctx, "casting did not generate any materials for writing") return nil } @@ -92,13 +65,6 @@ func (foundry *Foundry) Forge(ctx context.Context, machinery v1alpha1.Machinery, return err } - if len(infraMaterials) > 0 { - foundry.Logger.InfoContext(ctx, "writing infrastructure materials", slog.Int("count", len(infraMaterials))) - if err := poursWriter.WriteMany(ctx, infraMaterials...); err != nil { - return err - } - } - foundry.Logger.InfoContext(ctx, "writing materials") if err := poursWriter.WriteMany(ctx, materials...); err != nil { return err diff --git a/internal/foundry/foundry.go b/internal/foundry/foundry.go index d544a132..b92ccca9 100644 --- a/internal/foundry/foundry.go +++ b/internal/foundry/foundry.go @@ -6,14 +6,14 @@ import ( "github.com/signoz/foundry/api/v1alpha1" "github.com/signoz/foundry/api/v1alpha1/collectionagent" + infrastructurev1alpha1 "github.com/signoz/foundry/api/v1alpha1/infrastructure" "github.com/signoz/foundry/api/v1alpha1/installation" collectionagentcasting "github.com/signoz/foundry/internal/casting/collectionagent" + infrastructurecasting "github.com/signoz/foundry/internal/casting/infrastructure" installationcasting "github.com/signoz/foundry/internal/casting/installation" "github.com/signoz/foundry/internal/config" "github.com/signoz/foundry/internal/config/yamlconfig" foundryerrors "github.com/signoz/foundry/internal/errors" - "github.com/signoz/foundry/internal/infrastructure" - terraformgenerator "github.com/signoz/foundry/internal/infrastructure/terraform" "github.com/signoz/foundry/internal/patch" "github.com/signoz/foundry/internal/patch/jsonpatch" "github.com/signoz/foundry/internal/planner" @@ -33,9 +33,6 @@ type Foundry struct { // Planners for the different casting kinds. Planners map[v1alpha1.Kind]plannerCtor - - // InfrastructureGenerator for generating infrastructure-as-code manifests. - InfrastructureGenerator infrastructure.Generator } func New(logger *slog.Logger) (*Foundry, error) { @@ -52,8 +49,10 @@ func New(logger *slog.Logger) (*Foundry, error) { v1alpha1.KindCollectionAgent: func(ctx context.Context, m v1alpha1.Machinery, logger *slog.Logger) (planner.Planner, error) { return collectionagentcasting.NewPlanner(ctx, m.(*collectionagent.Casting), logger) }, + v1alpha1.KindInfrastructure: func(ctx context.Context, m v1alpha1.Machinery, logger *slog.Logger) (planner.Planner, error) { + return infrastructurecasting.NewPlanner(ctx, m.(*infrastructurev1alpha1.Casting), logger) + }, }, - InfrastructureGenerator: terraformgenerator.New(logger), }, nil } diff --git a/internal/infrastructure/compute_type.go b/internal/infrastructure/compute_type.go deleted file mode 100644 index 37e77f7f..00000000 --- a/internal/infrastructure/compute_type.go +++ /dev/null @@ -1,91 +0,0 @@ -package infrastructure - -import ( - "encoding/json" - "errors" - "fmt" - - "go.yaml.in/yaml/v3" -) - -var _ yaml.Marshaler = (*ComputeType)(nil) -var _ yaml.Unmarshaler = (*ComputeType)(nil) -var _ json.Marshaler = (*ComputeType)(nil) -var _ json.Unmarshaler = (*ComputeType)(nil) -var _ fmt.Stringer = (*ComputeType)(nil) - -var ( - // AWS compute types. - ComputeTypeEC2 ComputeType = ComputeType{s: "ec2"} - ComputeTypeEKS ComputeType = ComputeType{s: "eks"} - // GCP compute types. - ComputeTypeGCE ComputeType = ComputeType{s: "gce"} - ComputeTypeGKE ComputeType = ComputeType{s: "gke"} - // Azure compute types. - ComputeTypeVM ComputeType = ComputeType{s: "vm"} - ComputeTypeAKS ComputeType = ComputeType{s: "aks"} -) - -// ComputeType identifies the compute resource type for a given cloud provider. -// It is an internal type resolved from the provider + deployment combination — -// users do not set this directly. -type ComputeType struct { - s string -} - -func (c ComputeType) String() string { - return c.s -} - -func (c ComputeType) IsZero() bool { - return c.s == "" -} - -func ComputeTypes() []ComputeType { - return []ComputeType{ - ComputeTypeEC2, - ComputeTypeEKS, - ComputeTypeGCE, - ComputeTypeGKE, - ComputeTypeVM, - ComputeTypeAKS, - } -} - -func (c ComputeType) MarshalJSON() ([]byte, error) { - return json.Marshal(c.String()) -} - -func (c *ComputeType) UnmarshalJSON(text []byte) error { - var str string - if err := json.Unmarshal(text, &str); err != nil { - return err - } - return c.UnmarshalText([]byte(str)) -} - -func (c *ComputeType) UnmarshalText(text []byte) error { - for _, available := range ComputeTypes() { - if available.String() == string(text) { - *c = available - return nil - } - } - if len(text) == 0 { - *c = ComputeType{s: ""} - return nil - } - return errors.New("invalid infrastructure compute type: " + string(text)) -} - -func (c ComputeType) MarshalText() ([]byte, error) { - return []byte(c.String()), nil -} - -func (c *ComputeType) UnmarshalYAML(node *yaml.Node) error { - return c.UnmarshalText([]byte(node.Value)) -} - -func (c ComputeType) MarshalYAML() (any, error) { - return c.String(), nil -} diff --git a/internal/infrastructure/generator.go b/internal/infrastructure/generator.go deleted file mode 100644 index 9e7983dd..00000000 --- a/internal/infrastructure/generator.go +++ /dev/null @@ -1,20 +0,0 @@ -package infrastructure - -import ( - "context" - - "github.com/signoz/foundry/api/v1alpha1/installation" - "github.com/signoz/foundry/internal/domain" -) - -// Generator is the interface for infrastructure-as-code generators. -// Implementations produce IaC manifests (e.g., Terraform, Pulumi) from a casting configuration -// and can validate the generated output using the underlying tool. -type Generator interface { - // Generate produces IaC materials from the casting configuration. - Generate(ctx context.Context, config installation.Casting) ([]domain.Material, error) - - // Validate runs the IaC tool's built-in validation (e.g., terraform validate) - // against the manifests written to poursPath. - Validate(ctx context.Context, poursPath string) error -} diff --git a/internal/infrastructure/resolve.go b/internal/infrastructure/resolve.go deleted file mode 100644 index d8324ccc..00000000 --- a/internal/infrastructure/resolve.go +++ /dev/null @@ -1,61 +0,0 @@ -package infrastructure - -import ( - "github.com/signoz/foundry/api/v1alpha1" - "github.com/signoz/foundry/internal/errors" -) - -// ResolveProvider normalizes a deployment platform to the cloud platform that -// hosts it. Only the cloud platforms (aws, gcp, azure) and ECS resolve; -// managed platforms (render, coolify, railway) have no IaC backing. -func ResolveProvider(platform v1alpha1.Platform) (v1alpha1.Platform, error) { - switch platform { - case v1alpha1.PlatformAWS, v1alpha1.PlatformECS: - return v1alpha1.PlatformAWS, nil - case v1alpha1.PlatformGCP: - return v1alpha1.PlatformGCP, nil - case v1alpha1.PlatformAzure: - return v1alpha1.PlatformAzure, nil - case v1alpha1.Platform{}: - return v1alpha1.Platform{}, errors.Newf(errors.TypeInvalidInput, "no platform specified in deployment.platform: infrastructure generation requires aws, gcp, or azure") - default: - return v1alpha1.Platform{}, errors.Newf(errors.TypeUnsupported, "unsupported platform for infrastructure generation: %q (must be aws, gcp, azure, or ecs)", platform) - } -} - -// ResolveComputeType derives the appropriate ComputeType from a cloud platform -// and deployment configuration. Users do not specify the compute type directly -// — foundry resolves it automatically using this matrix: -// -// AWS + kubernetes (any flavor) → EKS -// AWS + anything else → EC2 -// GCP + kubernetes (any flavor) → GKE -// GCP + anything else → GCE -// Azure + kubernetes (any flavor) → AKS -// Azure + anything else → VM -func ResolveComputeType(provider v1alpha1.Platform, deployment v1alpha1.TypeDeployment) (ComputeType, error) { - isKubernetes := deployment.Mode == v1alpha1.ModeKubernetes - - switch provider { - case v1alpha1.PlatformAWS: - if isKubernetes { - return ComputeTypeEKS, nil - } - return ComputeTypeEC2, nil - - case v1alpha1.PlatformGCP: - if isKubernetes { - return ComputeTypeGKE, nil - } - return ComputeTypeGCE, nil - - case v1alpha1.PlatformAzure: - if isKubernetes { - return ComputeTypeAKS, nil - } - return ComputeTypeVM, nil - - default: - return ComputeType{}, errors.Newf(errors.TypeUnsupported, "unsupported infrastructure platform: %s", provider) - } -} diff --git a/internal/infrastructure/terraform/embed.go b/internal/infrastructure/terraform/embed.go deleted file mode 100644 index 914ab8dc..00000000 --- a/internal/infrastructure/terraform/embed.go +++ /dev/null @@ -1,57 +0,0 @@ -package terraform - -import ( - "embed" - - "github.com/signoz/foundry/internal/domain" -) - -//go:embed templates/*.gotmpl templates/aws/ec2/*.gotmpl templates/aws/eks/*.gotmpl templates/gcp/gce/*.gotmpl templates/gcp/gke/*.gotmpl templates/azure/vm/*.gotmpl templates/azure/aks/*.gotmpl -var templates embed.FS - -// Common templates. -var ( - providersTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/providers.tf.json.gotmpl", domain.FormatJSON) -) - -// AWS EC2 templates. -var ( - awsEC2MainTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/aws/ec2/main.tf.json.gotmpl", domain.FormatJSON) - awsEC2VariablesTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/aws/ec2/variables.tf.json.gotmpl", domain.FormatJSON) - awsEC2OutputsTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/aws/ec2/outputs.tf.json.gotmpl", domain.FormatJSON) -) - -// AWS EKS templates. -var ( - awsEKSMainTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/aws/eks/main.tf.json.gotmpl", domain.FormatJSON) - awsEKSVariablesTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/aws/eks/variables.tf.json.gotmpl", domain.FormatJSON) - awsEKSOutputsTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/aws/eks/outputs.tf.json.gotmpl", domain.FormatJSON) -) - -// GCP GCE templates. -var ( - gcpGCEMainTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/gcp/gce/main.tf.json.gotmpl", domain.FormatJSON) - gcpGCEVariablesTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/gcp/gce/variables.tf.json.gotmpl", domain.FormatJSON) - gcpGCEOutputsTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/gcp/gce/outputs.tf.json.gotmpl", domain.FormatJSON) -) - -// GCP GKE templates. -var ( - gcpGKEMainTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/gcp/gke/main.tf.json.gotmpl", domain.FormatJSON) - gcpGKEVariablesTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/gcp/gke/variables.tf.json.gotmpl", domain.FormatJSON) - gcpGKEOutputsTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/gcp/gke/outputs.tf.json.gotmpl", domain.FormatJSON) -) - -// Azure VM templates. -var ( - azureVMMainTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/azure/vm/main.tf.json.gotmpl", domain.FormatJSON) - azureVMVariablesTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/azure/vm/variables.tf.json.gotmpl", domain.FormatJSON) - azureVMOutputsTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/azure/vm/outputs.tf.json.gotmpl", domain.FormatJSON) -) - -// Azure AKS templates. -var ( - azureAKSMainTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/azure/aks/main.tf.json.gotmpl", domain.FormatJSON) - azureAKSVariablesTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/azure/aks/variables.tf.json.gotmpl", domain.FormatJSON) - azureAKSOutputsTFTemplate *domain.Template = domain.MustNewTemplateFromFS(templates, "templates/azure/aks/outputs.tf.json.gotmpl", domain.FormatJSON) -) diff --git a/internal/infrastructure/terraform/generator.go b/internal/infrastructure/terraform/generator.go deleted file mode 100644 index 5b7a7be8..00000000 --- a/internal/infrastructure/terraform/generator.go +++ /dev/null @@ -1,130 +0,0 @@ -package terraform - -import ( - "context" - "log/slog" - "os/exec" - "path/filepath" - - "github.com/signoz/foundry/api/v1alpha1" - "github.com/signoz/foundry/api/v1alpha1/installation" - "github.com/signoz/foundry/internal/domain" - "github.com/signoz/foundry/internal/errors" - "github.com/signoz/foundry/internal/infrastructure" -) - -var _ infrastructure.Generator = (*Generator)(nil) - -const infrastructureDir = "infrastructure" - -// Generator generates Terraform manifests for infrastructure deployment. -type Generator struct { - logger *slog.Logger -} - -type templateData struct { - installation.Casting - Provider v1alpha1.Platform - ComputeType infrastructure.ComputeType -} - -// New creates a new Terraform Generator. -func New(logger *slog.Logger) *Generator { - return &Generator{ - logger: logger, - } -} - -// Generate creates Terraform manifests based on the casting configuration. -// The compute type is resolved automatically from the provider and deployment mode. -func (g *Generator) Generate(ctx context.Context, config installation.Casting) ([]domain.Material, error) { - if !config.Spec.Infrastructure.Enabled { - return nil, nil - } - - provider, err := infrastructure.ResolveProvider(config.Spec.Deployment.Platform) - if err != nil { - return nil, err - } - computeType, err := infrastructure.ResolveComputeType(provider, config.Spec.Deployment) - if err != nil { - return nil, err - } - - g.logger.InfoContext(ctx, "generating terraform manifests", - slog.String("provider", provider.String()), - slog.String("computeType", computeType.String()), - ) - - data := templateData{ - Casting: config, - Provider: provider, - ComputeType: computeType, - } - - mainTemplate, varsTemplate, outputsTemplate, err := g.templatesFor(provider, computeType) - if err != nil { - return nil, err - } - - materials := make([]domain.Material, 0, 4) - for _, item := range []struct { - tmpl *domain.Template - path string - }{ - {mainTemplate, "main.tf.json"}, - {varsTemplate, "variables.tf.json"}, - {providersTFTemplate, "providers.tf.json"}, - {outputsTemplate, "outputs.tf.json"}, - } { - m, err := item.tmpl.Render(data, filepath.Join(infrastructureDir, item.path)) - if err != nil { - return nil, errors.Wrapf(err, errors.TypeInternal, "failed to render %s", item.path) - } - materials = append(materials, m) - } - - return materials, nil -} - -// Validate runs `terraform validate` against the manifests in poursPath/infrastructure. -func (g *Generator) Validate(ctx context.Context, poursPath string) error { - infraDir := filepath.Join(poursPath, infrastructureDir) - g.logger.InfoContext(ctx, "validating terraform manifests", slog.String("path", infraDir)) - - cmd := exec.CommandContext(ctx, "terraform", "validate") - cmd.Dir = infraDir - out, err := cmd.CombinedOutput() - if err != nil { - return errors.Wrapf(err, errors.TypeInternal, "terraform validate failed\n%s", out) - } - return nil -} - -// templatesFor returns the provider+compute-type specific templates. -func (g *Generator) templatesFor(provider v1alpha1.Platform, computeType infrastructure.ComputeType) (main, vars, outputs *domain.Template, err error) { - switch provider { - case v1alpha1.PlatformAWS: - switch computeType { - case infrastructure.ComputeTypeEC2: - return awsEC2MainTFTemplate, awsEC2VariablesTFTemplate, awsEC2OutputsTFTemplate, nil - case infrastructure.ComputeTypeEKS: - return awsEKSMainTFTemplate, awsEKSVariablesTFTemplate, awsEKSOutputsTFTemplate, nil - } - case v1alpha1.PlatformGCP: - switch computeType { - case infrastructure.ComputeTypeGCE: - return gcpGCEMainTFTemplate, gcpGCEVariablesTFTemplate, gcpGCEOutputsTFTemplate, nil - case infrastructure.ComputeTypeGKE: - return gcpGKEMainTFTemplate, gcpGKEVariablesTFTemplate, gcpGKEOutputsTFTemplate, nil - } - case v1alpha1.PlatformAzure: - switch computeType { - case infrastructure.ComputeTypeVM: - return azureVMMainTFTemplate, azureVMVariablesTFTemplate, azureVMOutputsTFTemplate, nil - case infrastructure.ComputeTypeAKS: - return azureAKSMainTFTemplate, azureAKSVariablesTFTemplate, azureAKSOutputsTFTemplate, nil - } - } - return nil, nil, nil, errors.Newf(errors.TypeUnsupported, "unsupported provider %q / compute type %q combination", provider, computeType) -} diff --git a/internal/infrastructure/terraform/templates/aws/ec2/main.tf.json.gotmpl b/internal/infrastructure/terraform/templates/aws/ec2/main.tf.json.gotmpl deleted file mode 100644 index 3ede88d5..00000000 --- a/internal/infrastructure/terraform/templates/aws/ec2/main.tf.json.gotmpl +++ /dev/null @@ -1,456 +0,0 @@ -{ - "locals": { - "name": "{{ .Metadata.Name }}", - "common_tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "data": { - "aws_availability_zones": { - "available": { - "state": "available" - } - }, - "aws_ami": { - "ubuntu": { - "most_recent": true, - "owners": ["099720109477"], - "filter": [ - { - "name": "name", - "values": ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] - }, - { - "name": "virtualization-type", - "values": ["hvm"] - } - ] - } - } - }, - "resource": { - "aws_vpc": { - "main": { - "cidr_block": "${var.vpc_cidr}", - "enable_dns_hostnames": true, - "enable_dns_support": true, - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-vpc" - } - } - }, - "aws_subnet": { - "private": { - "count": "${var.az_count}", - "vpc_id": "${aws_vpc.main.id}", - "cidr_block": "${cidrsubnet(var.vpc_cidr, 8, count.index)}", - "availability_zone": "${data.aws_availability_zones.available.names[count.index]}", - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-private-${count.index}" - } - }, - "public": { - "count": "${var.az_count}", - "vpc_id": "${aws_vpc.main.id}", - "cidr_block": "${cidrsubnet(var.vpc_cidr, 8, count.index + var.az_count)}", - "availability_zone": "${data.aws_availability_zones.available.names[count.index]}", - "map_public_ip_on_launch": true, - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-public-${count.index}" - } - } - }, - "aws_internet_gateway": { - "main": { - "vpc_id": "${aws_vpc.main.id}", - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-igw" - } - } - }, - "aws_eip": { - "nat": { - "count": "${var.az_count}", - "domain": "vpc", - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-nat-eip-${count.index}" - } - } - }, - "aws_nat_gateway": { - "main": { - "count": "${var.az_count}", - "allocation_id": "${aws_eip.nat[count.index].id}", - "subnet_id": "${aws_subnet.public[count.index].id}", - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-nat-${count.index}" - }, - "depends_on": ["${aws_internet_gateway.main}"] - } - }, - "aws_route_table": { - "public": { - "vpc_id": "${aws_vpc.main.id}", - "route": [ - { - "cidr_block": "0.0.0.0/0", - "gateway_id": "${aws_internet_gateway.main.id}" - } - ], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-public-rt" - } - }, - "private": { - "count": "${var.az_count}", - "vpc_id": "${aws_vpc.main.id}", - "route": [ - { - "cidr_block": "0.0.0.0/0", - "nat_gateway_id": "${aws_nat_gateway.main[count.index].id}" - } - ], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-private-rt-${count.index}" - } - } - }, - "aws_route_table_association": { - "public": { - "count": "${var.az_count}", - "subnet_id": "${aws_subnet.public[count.index].id}", - "route_table_id": "${aws_route_table.public.id}" - }, - "private": { - "count": "${var.az_count}", - "subnet_id": "${aws_subnet.private[count.index].id}", - "route_table_id": "${aws_route_table.private[count.index].id}" - } - }, - "aws_security_group": { - "telemetrykeeper": { - "name": "${local.name}-telemetrykeeper-sg", - "description": "Security group for TelemetryKeeper (ClickHouse Keeper)", - "vpc_id": "${aws_vpc.main.id}", - "ingress": [ - { - "from_port": 9181, - "to_port": 9181, - "protocol": "tcp", - "cidr_blocks": ["${var.vpc_cidr}"], - "description": "ClickHouse Keeper client port" - }, - { - "from_port": 9234, - "to_port": 9234, - "protocol": "tcp", - "cidr_blocks": ["${var.vpc_cidr}"], - "description": "ClickHouse Keeper raft port" - }, - { - "from_port": 22, - "to_port": 22, - "protocol": "tcp", - "cidr_blocks": ["${var.vpc_cidr}"], - "description": "SSH access from VPC" - } - ], - "egress": [ - { - "from_port": 0, - "to_port": 0, - "protocol": "-1", - "cidr_blocks": ["0.0.0.0/0"] - } - ], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-telemetrykeeper-sg" - } - }, - "telemetrystore": { - "name": "${local.name}-telemetrystore-sg", - "description": "Security group for TelemetryStore (ClickHouse)", - "vpc_id": "${aws_vpc.main.id}", - "ingress": [ - { - "from_port": 9000, - "to_port": 9000, - "protocol": "tcp", - "cidr_blocks": ["${var.vpc_cidr}"], - "description": "ClickHouse native port" - }, - { - "from_port": 8123, - "to_port": 8123, - "protocol": "tcp", - "cidr_blocks": ["${var.vpc_cidr}"], - "description": "ClickHouse HTTP port" - }, - { - "from_port": 22, - "to_port": 22, - "protocol": "tcp", - "cidr_blocks": ["${var.vpc_cidr}"], - "description": "SSH access from VPC" - } - ], - "egress": [ - { - "from_port": 0, - "to_port": 0, - "protocol": "-1", - "cidr_blocks": ["0.0.0.0/0"] - } - ], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-telemetrystore-sg" - } - }, - "metastore": { - "name": "${local.name}-metastore-sg", - "description": "Security group for MetaStore (PostgreSQL)", - "vpc_id": "${aws_vpc.main.id}", - "ingress": [ - { - "from_port": 5432, - "to_port": 5432, - "protocol": "tcp", - "cidr_blocks": ["${var.vpc_cidr}"], - "description": "PostgreSQL port" - }, - { - "from_port": 22, - "to_port": 22, - "protocol": "tcp", - "cidr_blocks": ["${var.vpc_cidr}"], - "description": "SSH access from VPC" - } - ], - "egress": [ - { - "from_port": 0, - "to_port": 0, - "protocol": "-1", - "cidr_blocks": ["0.0.0.0/0"] - } - ], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-metastore-sg" - } - }, - "ingester": { - "name": "${local.name}-ingester-sg", - "description": "Security group for Ingester (OpenTelemetry Collector)", - "vpc_id": "${aws_vpc.main.id}", - "ingress": [ - { - "from_port": 4317, - "to_port": 4317, - "protocol": "tcp", - "cidr_blocks": ["0.0.0.0/0"], - "description": "OTLP gRPC port" - }, - { - "from_port": 4318, - "to_port": 4318, - "protocol": "tcp", - "cidr_blocks": ["0.0.0.0/0"], - "description": "OTLP HTTP port" - }, - { - "from_port": 22, - "to_port": 22, - "protocol": "tcp", - "cidr_blocks": ["${var.vpc_cidr}"], - "description": "SSH access from VPC" - } - ], - "egress": [ - { - "from_port": 0, - "to_port": 0, - "protocol": "-1", - "cidr_blocks": ["0.0.0.0/0"] - } - ], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-ingester-sg" - } - }, - "signoz": { - "name": "${local.name}-signoz-sg", - "description": "Security group for SigNoz", - "vpc_id": "${aws_vpc.main.id}", - "ingress": [ - { - "from_port": 8080, - "to_port": 8080, - "protocol": "tcp", - "cidr_blocks": ["0.0.0.0/0"], - "description": "SigNoz UI port" - }, - { - "from_port": 3301, - "to_port": 3301, - "protocol": "tcp", - "cidr_blocks": ["${var.vpc_cidr}"], - "description": "SigNoz API port" - }, - { - "from_port": 22, - "to_port": 22, - "protocol": "tcp", - "cidr_blocks": ["${var.vpc_cidr}"], - "description": "SSH access from VPC" - } - ], - "egress": [ - { - "from_port": 0, - "to_port": 0, - "protocol": "-1", - "cidr_blocks": ["0.0.0.0/0"] - } - ], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-signoz-sg" - } - } - }, - "aws_key_pair": { - "main": { - "count": "${var.ssh_public_key != \"\" ? 1 : 0}", - "key_name": "${local.name}-key", - "public_key": "${var.ssh_public_key}", - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - } - }, - "aws_instance": { - "telemetrykeeper": { - "count": {{ if .Spec.TelemetryKeeper.Spec.Enabled }}{{ derefInt .Spec.TelemetryKeeper.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "ami": "${data.aws_ami.ubuntu.id}", - "instance_type": "${var.telemetrykeeper_instance_type}", - "subnet_id": "${aws_subnet.private[count.index % var.az_count].id}", - "vpc_security_group_ids": ["${aws_security_group.telemetrykeeper.id}"], - "key_name": "${var.ssh_public_key != \"\" ? aws_key_pair.main[0].key_name : null}", - "root_block_device": [{ - "volume_size": "${var.telemetrykeeper_volume_size}", - "volume_type": "gp3", - "encrypted": true - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-telemetrykeeper-${count.index}", - "Role": "telemetrykeeper" - } - }, - "telemetrystore": { - "count": {{ if .Spec.TelemetryStore.Spec.Enabled }}{{ derefInt .Spec.TelemetryStore.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "ami": "${data.aws_ami.ubuntu.id}", - "instance_type": "${var.telemetrystore_instance_type}", - "subnet_id": "${aws_subnet.private[count.index % var.az_count].id}", - "vpc_security_group_ids": ["${aws_security_group.telemetrystore.id}"], - "key_name": "${var.ssh_public_key != \"\" ? aws_key_pair.main[0].key_name : null}", - "root_block_device": [{ - "volume_size": "${var.telemetrystore_volume_size}", - "volume_type": "gp3", - "encrypted": true - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-telemetrystore-${count.index}", - "Role": "telemetrystore" - } - }, - "metastore": { - "count": {{ if .Spec.MetaStore.Spec.Enabled }}{{ derefInt .Spec.MetaStore.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "ami": "${data.aws_ami.ubuntu.id}", - "instance_type": "${var.metastore_instance_type}", - "subnet_id": "${aws_subnet.private[count.index % var.az_count].id}", - "vpc_security_group_ids": ["${aws_security_group.metastore.id}"], - "key_name": "${var.ssh_public_key != \"\" ? aws_key_pair.main[0].key_name : null}", - "root_block_device": [{ - "volume_size": "${var.metastore_volume_size}", - "volume_type": "gp3", - "encrypted": true - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-metastore-${count.index}", - "Role": "metastore" - } - }, - "ingester": { - "count": {{ if .Spec.Ingester.Spec.Enabled }}{{ derefInt .Spec.Ingester.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "ami": "${data.aws_ami.ubuntu.id}", - "instance_type": "${var.ingester_instance_type}", - "subnet_id": "${aws_subnet.public[count.index % var.az_count].id}", - "vpc_security_group_ids": ["${aws_security_group.ingester.id}"], - "key_name": "${var.ssh_public_key != \"\" ? aws_key_pair.main[0].key_name : null}", - "root_block_device": [{ - "volume_size": "${var.ingester_volume_size}", - "volume_type": "gp3", - "encrypted": true - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-ingester-${count.index}", - "Role": "ingester" - } - }, - "signoz": { - "count": {{ if .Spec.Signoz.Spec.Enabled }}{{ derefInt .Spec.Signoz.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "ami": "${data.aws_ami.ubuntu.id}", - "instance_type": "${var.signoz_instance_type}", - "subnet_id": "${aws_subnet.public[count.index % var.az_count].id}", - "vpc_security_group_ids": ["${aws_security_group.signoz.id}"], - "key_name": "${var.ssh_public_key != \"\" ? aws_key_pair.main[0].key_name : null}", - "root_block_device": [{ - "volume_size": "${var.signoz_volume_size}", - "volume_type": "gp3", - "encrypted": true - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-signoz-${count.index}", - "Role": "signoz" - } - } - } - } -} diff --git a/internal/infrastructure/terraform/templates/aws/ec2/outputs.tf.json.gotmpl b/internal/infrastructure/terraform/templates/aws/ec2/outputs.tf.json.gotmpl deleted file mode 100644 index 38d7d0b6..00000000 --- a/internal/infrastructure/terraform/templates/aws/ec2/outputs.tf.json.gotmpl +++ /dev/null @@ -1,64 +0,0 @@ -{ - "output": { - "vpc_id": { - "description": "ID of the VPC", - "value": "${aws_vpc.main.id}" - }, - "private_subnet_ids": { - "description": "IDs of the private subnets", - "value": "${aws_subnet.private[*].id}" - }, - "public_subnet_ids": { - "description": "IDs of the public subnets", - "value": "${aws_subnet.public[*].id}" - }, - "telemetrykeeper_instance_ids": { - "description": "IDs of the TelemetryKeeper EC2 instances", - "value": "${aws_instance.telemetrykeeper[*].id}" - }, - "telemetrykeeper_private_ips": { - "description": "Private IP addresses of the TelemetryKeeper EC2 instances", - "value": "${aws_instance.telemetrykeeper[*].private_ip}" - }, - "telemetrystore_instance_ids": { - "description": "IDs of the TelemetryStore EC2 instances", - "value": "${aws_instance.telemetrystore[*].id}" - }, - "telemetrystore_private_ips": { - "description": "Private IP addresses of the TelemetryStore EC2 instances", - "value": "${aws_instance.telemetrystore[*].private_ip}" - }, - "metastore_instance_ids": { - "description": "IDs of the MetaStore EC2 instances", - "value": "${aws_instance.metastore[*].id}" - }, - "metastore_private_ips": { - "description": "Private IP addresses of the MetaStore EC2 instances", - "value": "${aws_instance.metastore[*].private_ip}" - }, - "ingester_instance_ids": { - "description": "IDs of the Ingester EC2 instances", - "value": "${aws_instance.ingester[*].id}" - }, - "ingester_public_ips": { - "description": "Public IP addresses of the Ingester EC2 instances", - "value": "${aws_instance.ingester[*].public_ip}" - }, - "ingester_private_ips": { - "description": "Private IP addresses of the Ingester EC2 instances", - "value": "${aws_instance.ingester[*].private_ip}" - }, - "signoz_instance_ids": { - "description": "IDs of the SigNoz EC2 instances", - "value": "${aws_instance.signoz[*].id}" - }, - "signoz_public_ips": { - "description": "Public IP addresses of the SigNoz EC2 instances", - "value": "${aws_instance.signoz[*].public_ip}" - }, - "signoz_private_ips": { - "description": "Private IP addresses of the SigNoz EC2 instances", - "value": "${aws_instance.signoz[*].private_ip}" - } - } -} diff --git a/internal/infrastructure/terraform/templates/aws/ec2/variables.tf.json.gotmpl b/internal/infrastructure/terraform/templates/aws/ec2/variables.tf.json.gotmpl deleted file mode 100644 index 1d1061d2..00000000 --- a/internal/infrastructure/terraform/templates/aws/ec2/variables.tf.json.gotmpl +++ /dev/null @@ -1,79 +0,0 @@ -{ - "variable": { - "aws_region": { - "description": "AWS region to deploy resources", - "type": "string", - "default": "us-east-1" - }, - "vpc_cidr": { - "description": "CIDR block for the VPC", - "type": "string", - "default": "10.0.0.0/16" - }, - "az_count": { - "description": "Number of availability zones to use", - "type": "number", - "default": 2 - }, - "name": { - "description": "The name of the deployment", - "type": "string", - "default": "{{ .Metadata.Name }}" - }, - "ssh_public_key": { - "description": "SSH public key for EC2 instance access. Leave empty to disable SSH key pair creation.", - "type": "string", - "default": "" - }, - "telemetrykeeper_instance_type": { - "description": "EC2 instance type for TelemetryKeeper", - "type": "string", - "default": "t3.small" - }, - "telemetrykeeper_volume_size": { - "description": "Root volume size (GB) for TelemetryKeeper instances", - "type": "number", - "default": 20 - }, - "telemetrystore_instance_type": { - "description": "EC2 instance type for TelemetryStore", - "type": "string", - "default": "r6i.xlarge" - }, - "telemetrystore_volume_size": { - "description": "Root volume size (GB) for TelemetryStore instances", - "type": "number", - "default": 100 - }, - "metastore_instance_type": { - "description": "EC2 instance type for MetaStore", - "type": "string", - "default": "t3.small" - }, - "metastore_volume_size": { - "description": "Root volume size (GB) for MetaStore instances", - "type": "number", - "default": 20 - }, - "ingester_instance_type": { - "description": "EC2 instance type for Ingester", - "type": "string", - "default": "t3.medium" - }, - "ingester_volume_size": { - "description": "Root volume size (GB) for Ingester instances", - "type": "number", - "default": 50 - }, - "signoz_instance_type": { - "description": "EC2 instance type for SigNoz", - "type": "string", - "default": "t3.medium" - }, - "signoz_volume_size": { - "description": "Root volume size (GB) for SigNoz instances", - "type": "number", - "default": 50 - } - } -} diff --git a/internal/infrastructure/terraform/templates/aws/eks/main.tf.json.gotmpl b/internal/infrastructure/terraform/templates/aws/eks/main.tf.json.gotmpl deleted file mode 100644 index 5376c049..00000000 --- a/internal/infrastructure/terraform/templates/aws/eks/main.tf.json.gotmpl +++ /dev/null @@ -1,327 +0,0 @@ -{ - "locals": { - "name": "{{ .Metadata.Name }}", - "common_tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "data": { - "aws_availability_zones": { - "available": { - "state": "available" - } - } - }, - "resource": { - "aws_vpc": { - "main": { - "cidr_block": "${var.vpc_cidr}", - "enable_dns_hostnames": true, - "enable_dns_support": true, - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-vpc", - "kubernetes.io/cluster/${local.name}": "shared" - } - } - }, - "aws_subnet": { - "private": { - "count": "${var.az_count}", - "vpc_id": "${aws_vpc.main.id}", - "cidr_block": "${cidrsubnet(var.vpc_cidr, 8, count.index)}", - "availability_zone": "${data.aws_availability_zones.available.names[count.index]}", - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-private-${count.index}", - "kubernetes.io/cluster/${local.name}": "shared", - "kubernetes.io/role/internal-elb": "1" - } - }, - "public": { - "count": "${var.az_count}", - "vpc_id": "${aws_vpc.main.id}", - "cidr_block": "${cidrsubnet(var.vpc_cidr, 8, count.index + var.az_count)}", - "availability_zone": "${data.aws_availability_zones.available.names[count.index]}", - "map_public_ip_on_launch": true, - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-public-${count.index}", - "kubernetes.io/cluster/${local.name}": "shared", - "kubernetes.io/role/elb": "1" - } - } - }, - "aws_internet_gateway": { - "main": { - "vpc_id": "${aws_vpc.main.id}", - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-igw" - } - } - }, - "aws_eip": { - "nat": { - "count": "${var.az_count}", - "domain": "vpc", - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-nat-eip-${count.index}" - } - } - }, - "aws_nat_gateway": { - "main": { - "count": "${var.az_count}", - "allocation_id": "${aws_eip.nat[count.index].id}", - "subnet_id": "${aws_subnet.public[count.index].id}", - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-nat-${count.index}" - }, - "depends_on": ["${aws_internet_gateway.main}"] - } - }, - "aws_route_table": { - "public": { - "vpc_id": "${aws_vpc.main.id}", - "route": [ - { - "cidr_block": "0.0.0.0/0", - "gateway_id": "${aws_internet_gateway.main.id}" - } - ], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-public-rt" - } - }, - "private": { - "count": "${var.az_count}", - "vpc_id": "${aws_vpc.main.id}", - "route": [ - { - "cidr_block": "0.0.0.0/0", - "nat_gateway_id": "${aws_nat_gateway.main[count.index].id}" - } - ], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-private-rt-${count.index}" - } - } - }, - "aws_route_table_association": { - "public": { - "count": "${var.az_count}", - "subnet_id": "${aws_subnet.public[count.index].id}", - "route_table_id": "${aws_route_table.public.id}" - }, - "private": { - "count": "${var.az_count}", - "subnet_id": "${aws_subnet.private[count.index].id}", - "route_table_id": "${aws_route_table.private[count.index].id}" - } - }, - "aws_iam_role": { - "eks_cluster": { - "name": "${local.name}-eks-cluster-role", - "assume_role_policy": "${jsonencode({\"Version\" = \"2012-10-17\", \"Statement\" = [{\"Action\" = \"sts:AssumeRole\", \"Effect\" = \"Allow\", \"Principal\" = {\"Service\" = \"eks.amazonaws.com\"}}]})}", - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "eks_node_group": { - "name": "${local.name}-eks-node-group-role", - "assume_role_policy": "${jsonencode({\"Version\" = \"2012-10-17\", \"Statement\" = [{\"Action\" = \"sts:AssumeRole\", \"Effect\" = \"Allow\", \"Principal\" = {\"Service\" = \"ec2.amazonaws.com\"}}]})}", - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - } - }, - "aws_iam_role_policy_attachment": { - "eks_cluster_policy": { - "policy_arn": "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy", - "role": "${aws_iam_role.eks_cluster.name}" - }, - "eks_worker_node_policy": { - "policy_arn": "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy", - "role": "${aws_iam_role.eks_node_group.name}" - }, - "eks_cni_policy": { - "policy_arn": "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy", - "role": "${aws_iam_role.eks_node_group.name}" - }, - "eks_container_registry": { - "policy_arn": "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly", - "role": "${aws_iam_role.eks_node_group.name}" - } - }, - "aws_eks_cluster": { - "main": { - "name": "${local.name}", - "role_arn": "${aws_iam_role.eks_cluster.arn}", - "version": "${var.kubernetes_version}", - "vpc_config": [{ - "subnet_ids": "${concat(aws_subnet.private[*].id, aws_subnet.public[*].id)}", - "endpoint_private_access": true, - "endpoint_public_access": true - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - }, - "depends_on": ["${aws_iam_role_policy_attachment.eks_cluster_policy}"] - } - }, - "aws_eks_node_group": { - "telemetrykeeper": { - "count": {{ if .Spec.TelemetryKeeper.Spec.Enabled }}1{{ else }}0{{ end }}, - "cluster_name": "${aws_eks_cluster.main.name}", - "node_group_name": "${local.name}-telemetrykeeper", - "node_role_arn": "${aws_iam_role.eks_node_group.arn}", - "subnet_ids": "${aws_subnet.private[*].id}", - "instance_types": ["${var.telemetrykeeper_instance_type}"], - "scaling_config": [{ - "desired_size": {{ derefInt .Spec.TelemetryKeeper.Spec.Cluster.Replicas }}, - "min_size": 1, - "max_size": {{ derefInt .Spec.TelemetryKeeper.Spec.Cluster.Replicas }} - }], - "disk_size": "${var.telemetrykeeper_volume_size}", - "labels": { - "role": "telemetrykeeper" - }, - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-telemetrykeeper" - }, - "depends_on": [ - "${aws_iam_role_policy_attachment.eks_worker_node_policy}", - "${aws_iam_role_policy_attachment.eks_cni_policy}", - "${aws_iam_role_policy_attachment.eks_container_registry}" - ] - }, - "telemetrystore": { - "count": {{ if .Spec.TelemetryStore.Spec.Enabled }}1{{ else }}0{{ end }}, - "cluster_name": "${aws_eks_cluster.main.name}", - "node_group_name": "${local.name}-telemetrystore", - "node_role_arn": "${aws_iam_role.eks_node_group.arn}", - "subnet_ids": "${aws_subnet.private[*].id}", - "instance_types": ["${var.telemetrystore_instance_type}"], - "scaling_config": [{ - "desired_size": {{ derefInt .Spec.TelemetryStore.Spec.Cluster.Replicas }}, - "min_size": 1, - "max_size": {{ derefInt .Spec.TelemetryStore.Spec.Cluster.Replicas }} - }], - "disk_size": "${var.telemetrystore_volume_size}", - "labels": { - "role": "telemetrystore" - }, - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-telemetrystore" - }, - "depends_on": [ - "${aws_iam_role_policy_attachment.eks_worker_node_policy}", - "${aws_iam_role_policy_attachment.eks_cni_policy}", - "${aws_iam_role_policy_attachment.eks_container_registry}" - ] - }, - "metastore": { - "count": {{ if .Spec.MetaStore.Spec.Enabled }}1{{ else }}0{{ end }}, - "cluster_name": "${aws_eks_cluster.main.name}", - "node_group_name": "${local.name}-metastore", - "node_role_arn": "${aws_iam_role.eks_node_group.arn}", - "subnet_ids": "${aws_subnet.private[*].id}", - "instance_types": ["${var.metastore_instance_type}"], - "scaling_config": [{ - "desired_size": {{ derefInt .Spec.MetaStore.Spec.Cluster.Replicas }}, - "min_size": 1, - "max_size": {{ derefInt .Spec.MetaStore.Spec.Cluster.Replicas }} - }], - "disk_size": "${var.metastore_volume_size}", - "labels": { - "role": "metastore" - }, - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-metastore" - }, - "depends_on": [ - "${aws_iam_role_policy_attachment.eks_worker_node_policy}", - "${aws_iam_role_policy_attachment.eks_cni_policy}", - "${aws_iam_role_policy_attachment.eks_container_registry}" - ] - }, - "ingester": { - "count": {{ if .Spec.Ingester.Spec.Enabled }}1{{ else }}0{{ end }}, - "cluster_name": "${aws_eks_cluster.main.name}", - "node_group_name": "${local.name}-ingester", - "node_role_arn": "${aws_iam_role.eks_node_group.arn}", - "subnet_ids": "${aws_subnet.public[*].id}", - "instance_types": ["${var.ingester_instance_type}"], - "scaling_config": [{ - "desired_size": {{ derefInt .Spec.Ingester.Spec.Cluster.Replicas }}, - "min_size": 1, - "max_size": {{ derefInt .Spec.Ingester.Spec.Cluster.Replicas }} - }], - "disk_size": "${var.ingester_volume_size}", - "labels": { - "role": "ingester" - }, - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-ingester" - }, - "depends_on": [ - "${aws_iam_role_policy_attachment.eks_worker_node_policy}", - "${aws_iam_role_policy_attachment.eks_cni_policy}", - "${aws_iam_role_policy_attachment.eks_container_registry}" - ] - }, - "signoz": { - "count": {{ if .Spec.Signoz.Spec.Enabled }}1{{ else }}0{{ end }}, - "cluster_name": "${aws_eks_cluster.main.name}", - "node_group_name": "${local.name}-signoz", - "node_role_arn": "${aws_iam_role.eks_node_group.arn}", - "subnet_ids": "${aws_subnet.public[*].id}", - "instance_types": ["${var.signoz_instance_type}"], - "scaling_config": [{ - "desired_size": {{ derefInt .Spec.Signoz.Spec.Cluster.Replicas }}, - "min_size": 1, - "max_size": {{ derefInt .Spec.Signoz.Spec.Cluster.Replicas }} - }], - "disk_size": "${var.signoz_volume_size}", - "labels": { - "role": "signoz" - }, - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Name": "${local.name}-signoz" - }, - "depends_on": [ - "${aws_iam_role_policy_attachment.eks_worker_node_policy}", - "${aws_iam_role_policy_attachment.eks_cni_policy}", - "${aws_iam_role_policy_attachment.eks_container_registry}" - ] - } - } - } -} diff --git a/internal/infrastructure/terraform/templates/aws/eks/outputs.tf.json.gotmpl b/internal/infrastructure/terraform/templates/aws/eks/outputs.tf.json.gotmpl deleted file mode 100644 index e071577c..00000000 --- a/internal/infrastructure/terraform/templates/aws/eks/outputs.tf.json.gotmpl +++ /dev/null @@ -1,73 +0,0 @@ -{ - "output": { - "cluster_name": { - "description": "Name of the EKS cluster", - "value": "${aws_eks_cluster.main.name}" - }, - "cluster_endpoint": { - "description": "Endpoint for the EKS cluster API server", - "value": "${aws_eks_cluster.main.endpoint}" - }, - "cluster_ca_certificate": { - "description": "Base64-encoded certificate authority data for the EKS cluster", - "value": "${aws_eks_cluster.main.certificate_authority[0].data}", - "sensitive": true - }, - "cluster_version": { - "description": "Kubernetes version of the EKS cluster", - "value": "${aws_eks_cluster.main.version}" - }, - "vpc_id": { - "description": "ID of the VPC", - "value": "${aws_vpc.main.id}" - }, - "private_subnet_ids": { - "description": "IDs of the private subnets", - "value": "${aws_subnet.private[*].id}" - }, - "public_subnet_ids": { - "description": "IDs of the public subnets", - "value": "${aws_subnet.public[*].id}" - }, - "telemetrykeeper_node_group_arn": { - "description": "ARN of the TelemetryKeeper node group", - "value": "${aws_eks_node_group.telemetrykeeper[*].arn}" - }, - "telemetrykeeper_node_group_status": { - "description": "Status of the TelemetryKeeper node group", - "value": "${aws_eks_node_group.telemetrykeeper[*].status}" - }, - "telemetrystore_node_group_arn": { - "description": "ARN of the TelemetryStore node group", - "value": "${aws_eks_node_group.telemetrystore[*].arn}" - }, - "telemetrystore_node_group_status": { - "description": "Status of the TelemetryStore node group", - "value": "${aws_eks_node_group.telemetrystore[*].status}" - }, - "metastore_node_group_arn": { - "description": "ARN of the MetaStore node group", - "value": "${aws_eks_node_group.metastore[*].arn}" - }, - "metastore_node_group_status": { - "description": "Status of the MetaStore node group", - "value": "${aws_eks_node_group.metastore[*].status}" - }, - "ingester_node_group_arn": { - "description": "ARN of the Ingester node group", - "value": "${aws_eks_node_group.ingester[*].arn}" - }, - "ingester_node_group_status": { - "description": "Status of the Ingester node group", - "value": "${aws_eks_node_group.ingester[*].status}" - }, - "signoz_node_group_arn": { - "description": "ARN of the SigNoz node group", - "value": "${aws_eks_node_group.signoz[*].arn}" - }, - "signoz_node_group_status": { - "description": "Status of the SigNoz node group", - "value": "${aws_eks_node_group.signoz[*].status}" - } - } -} diff --git a/internal/infrastructure/terraform/templates/aws/eks/variables.tf.json.gotmpl b/internal/infrastructure/terraform/templates/aws/eks/variables.tf.json.gotmpl deleted file mode 100644 index 47cfe41f..00000000 --- a/internal/infrastructure/terraform/templates/aws/eks/variables.tf.json.gotmpl +++ /dev/null @@ -1,79 +0,0 @@ -{ - "variable": { - "aws_region": { - "description": "AWS region to deploy resources", - "type": "string", - "default": "us-east-1" - }, - "vpc_cidr": { - "description": "CIDR block for the VPC", - "type": "string", - "default": "10.0.0.0/16" - }, - "az_count": { - "description": "Number of availability zones to use", - "type": "number", - "default": 2 - }, - "name": { - "description": "The name of the deployment", - "type": "string", - "default": "{{ .Metadata.Name }}" - }, - "kubernetes_version": { - "description": "Kubernetes version for the EKS cluster", - "type": "string", - "default": "1.30" - }, - "telemetrykeeper_instance_type": { - "description": "EC2 instance type for TelemetryKeeper node group", - "type": "string", - "default": "t3.small" - }, - "telemetrykeeper_volume_size": { - "description": "Root volume size (GB) for TelemetryKeeper nodes", - "type": "number", - "default": 20 - }, - "telemetrystore_instance_type": { - "description": "EC2 instance type for TelemetryStore node group", - "type": "string", - "default": "r6i.xlarge" - }, - "telemetrystore_volume_size": { - "description": "Root volume size (GB) for TelemetryStore nodes", - "type": "number", - "default": 100 - }, - "metastore_instance_type": { - "description": "EC2 instance type for MetaStore node group", - "type": "string", - "default": "t3.small" - }, - "metastore_volume_size": { - "description": "Root volume size (GB) for MetaStore nodes", - "type": "number", - "default": 20 - }, - "ingester_instance_type": { - "description": "EC2 instance type for Ingester node group", - "type": "string", - "default": "t3.medium" - }, - "ingester_volume_size": { - "description": "Root volume size (GB) for Ingester nodes", - "type": "number", - "default": 50 - }, - "signoz_instance_type": { - "description": "EC2 instance type for SigNoz node group", - "type": "string", - "default": "t3.medium" - }, - "signoz_volume_size": { - "description": "Root volume size (GB) for SigNoz nodes", - "type": "number", - "default": 50 - } - } -} diff --git a/internal/infrastructure/terraform/templates/azure/aks/main.tf.json.gotmpl b/internal/infrastructure/terraform/templates/azure/aks/main.tf.json.gotmpl deleted file mode 100644 index f0410ed2..00000000 --- a/internal/infrastructure/terraform/templates/azure/aks/main.tf.json.gotmpl +++ /dev/null @@ -1,149 +0,0 @@ -{ - "locals": { - "name": "{{ .Metadata.Name }}", - "common_tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "resource": { - "azurerm_resource_group": { - "main": { - "name": "${var.resource_group_name}", - "location": "${var.location}", - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - } - }, - "azurerm_virtual_network": { - "main": { - "name": "${local.name}-vnet", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "address_space": ["${var.vnet_cidr}"], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - } - }, - "azurerm_subnet": { - "aks": { - "name": "${local.name}-aks", - "resource_group_name": "${azurerm_resource_group.main.name}", - "virtual_network_name": "${azurerm_virtual_network.main.name}", - "address_prefixes": ["${var.aks_subnet_cidr}"] - } - }, - "azurerm_kubernetes_cluster": { - "main": { - "name": "${local.name}", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "dns_prefix": "${local.name}", - "kubernetes_version": "${var.kubernetes_version}", - "default_node_pool": [{ - "name": "system", - "node_count": 1, - "vm_size": "Standard_D2s_v3", - "vnet_subnet_id": "${azurerm_subnet.aks.id}", - "upgrade_settings": [{"max_surge": "10%"}] - }], - "identity": [{"type": "SystemAssigned"}], - "network_profile": [{ - "network_plugin": "azure", - "load_balancer_sku": "standard", - "outbound_type": "loadBalancer" - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - } - }, - "azurerm_kubernetes_cluster_node_pool": { - "telemetrykeeper": { - "count": {{ if .Spec.TelemetryKeeper.Spec.Enabled }}1{{ else }}0{{ end }}, - "name": "tkeepr", - "kubernetes_cluster_id": "${azurerm_kubernetes_cluster.main.id}", - "vm_size": "${var.telemetrykeeper_vm_size}", - "node_count": {{ derefInt .Spec.TelemetryKeeper.Spec.Cluster.Replicas }}, - "vnet_subnet_id": "${azurerm_subnet.aks.id}", - "os_disk_size_gb": "${var.telemetrykeeper_disk_size}", - "node_labels": { - "role": "telemetrykeeper" - }, - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "telemetrystore": { - "count": {{ if .Spec.TelemetryStore.Spec.Enabled }}1{{ else }}0{{ end }}, - "name": "tstore", - "kubernetes_cluster_id": "${azurerm_kubernetes_cluster.main.id}", - "vm_size": "${var.telemetrystore_vm_size}", - "node_count": {{ derefInt .Spec.TelemetryStore.Spec.Cluster.Replicas }}, - "vnet_subnet_id": "${azurerm_subnet.aks.id}", - "os_disk_size_gb": "${var.telemetrystore_disk_size}", - "node_labels": { - "role": "telemetrystore" - }, - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "metastore": { - "count": {{ if .Spec.MetaStore.Spec.Enabled }}1{{ else }}0{{ end }}, - "name": "metastore", - "kubernetes_cluster_id": "${azurerm_kubernetes_cluster.main.id}", - "vm_size": "${var.metastore_vm_size}", - "node_count": {{ derefInt .Spec.MetaStore.Spec.Cluster.Replicas }}, - "vnet_subnet_id": "${azurerm_subnet.aks.id}", - "os_disk_size_gb": "${var.metastore_disk_size}", - "node_labels": { - "role": "metastore" - }, - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "ingester": { - "count": {{ if .Spec.Ingester.Spec.Enabled }}1{{ else }}0{{ end }}, - "name": "ingester", - "kubernetes_cluster_id": "${azurerm_kubernetes_cluster.main.id}", - "vm_size": "${var.ingester_vm_size}", - "node_count": {{ derefInt .Spec.Ingester.Spec.Cluster.Replicas }}, - "vnet_subnet_id": "${azurerm_subnet.aks.id}", - "os_disk_size_gb": "${var.ingester_disk_size}", - "node_labels": { - "role": "ingester" - }, - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "signoz": { - "count": {{ if .Spec.Signoz.Spec.Enabled }}1{{ else }}0{{ end }}, - "name": "signoz", - "kubernetes_cluster_id": "${azurerm_kubernetes_cluster.main.id}", - "vm_size": "${var.signoz_vm_size}", - "node_count": {{ derefInt .Spec.Signoz.Spec.Cluster.Replicas }}, - "vnet_subnet_id": "${azurerm_subnet.aks.id}", - "os_disk_size_gb": "${var.signoz_disk_size}", - "node_labels": { - "role": "signoz" - }, - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - } - } - } -} diff --git a/internal/infrastructure/terraform/templates/azure/aks/outputs.tf.json.gotmpl b/internal/infrastructure/terraform/templates/azure/aks/outputs.tf.json.gotmpl deleted file mode 100644 index 612cb035..00000000 --- a/internal/infrastructure/terraform/templates/azure/aks/outputs.tf.json.gotmpl +++ /dev/null @@ -1,53 +0,0 @@ -{ - "output": { - "cluster_name": { - "description": "Name of the AKS cluster", - "value": "${azurerm_kubernetes_cluster.main.name}" - }, - "cluster_fqdn": { - "description": "FQDN of the AKS cluster", - "value": "${azurerm_kubernetes_cluster.main.fqdn}" - }, - "kube_config": { - "description": "Raw kubeconfig for the AKS cluster", - "value": "${azurerm_kubernetes_cluster.main.kube_config_raw}", - "sensitive": true - }, - "cluster_identity_principal_id": { - "description": "Principal ID of the AKS cluster managed identity", - "value": "${azurerm_kubernetes_cluster.main.identity[0].principal_id}" - }, - "resource_group_name": { - "description": "Name of the Azure resource group", - "value": "${azurerm_resource_group.main.name}" - }, - "vnet_id": { - "description": "ID of the virtual network", - "value": "${azurerm_virtual_network.main.id}" - }, - "aks_subnet_id": { - "description": "ID of the AKS subnet", - "value": "${azurerm_subnet.aks.id}" - }, - "telemetrykeeper_node_pool_id": { - "description": "ID of the TelemetryKeeper node pool", - "value": "${azurerm_kubernetes_cluster_node_pool.telemetrykeeper[*].id}" - }, - "telemetrystore_node_pool_id": { - "description": "ID of the TelemetryStore node pool", - "value": "${azurerm_kubernetes_cluster_node_pool.telemetrystore[*].id}" - }, - "metastore_node_pool_id": { - "description": "ID of the MetaStore node pool", - "value": "${azurerm_kubernetes_cluster_node_pool.metastore[*].id}" - }, - "ingester_node_pool_id": { - "description": "ID of the Ingester node pool", - "value": "${azurerm_kubernetes_cluster_node_pool.ingester[*].id}" - }, - "signoz_node_pool_id": { - "description": "ID of the SigNoz node pool", - "value": "${azurerm_kubernetes_cluster_node_pool.signoz[*].id}" - } - } -} diff --git a/internal/infrastructure/terraform/templates/azure/aks/variables.tf.json.gotmpl b/internal/infrastructure/terraform/templates/azure/aks/variables.tf.json.gotmpl deleted file mode 100644 index af39836b..00000000 --- a/internal/infrastructure/terraform/templates/azure/aks/variables.tf.json.gotmpl +++ /dev/null @@ -1,83 +0,0 @@ -{ - "variable": { - "resource_group_name": { - "description": "Azure resource group name", - "type": "string" - }, - "location": { - "description": "Azure region to deploy resources", - "type": "string", - "default": "eastus" - }, - "name": { - "description": "The name of the deployment", - "type": "string", - "default": "{{ .Metadata.Name }}" - }, - "vnet_cidr": { - "description": "CIDR block for the virtual network", - "type": "string", - "default": "10.0.0.0/8" - }, - "aks_subnet_cidr": { - "description": "CIDR block for the AKS subnet", - "type": "string", - "default": "10.240.0.0/16" - }, - "kubernetes_version": { - "description": "Kubernetes version for the AKS cluster (leave empty for latest)", - "type": "string", - "default": "" - }, - "telemetrykeeper_vm_size": { - "description": "Azure VM size for TelemetryKeeper node pool", - "type": "string", - "default": "Standard_B2s" - }, - "telemetrykeeper_disk_size": { - "description": "OS disk size (GB) for TelemetryKeeper nodes", - "type": "number", - "default": 20 - }, - "telemetrystore_vm_size": { - "description": "Azure VM size for TelemetryStore node pool", - "type": "string", - "default": "Standard_E4s_v3" - }, - "telemetrystore_disk_size": { - "description": "OS disk size (GB) for TelemetryStore nodes", - "type": "number", - "default": 100 - }, - "metastore_vm_size": { - "description": "Azure VM size for MetaStore node pool", - "type": "string", - "default": "Standard_B2s" - }, - "metastore_disk_size": { - "description": "OS disk size (GB) for MetaStore nodes", - "type": "number", - "default": 20 - }, - "ingester_vm_size": { - "description": "Azure VM size for Ingester node pool", - "type": "string", - "default": "Standard_B4ms" - }, - "ingester_disk_size": { - "description": "OS disk size (GB) for Ingester nodes", - "type": "number", - "default": 50 - }, - "signoz_vm_size": { - "description": "Azure VM size for SigNoz node pool", - "type": "string", - "default": "Standard_B4ms" - }, - "signoz_disk_size": { - "description": "OS disk size (GB) for SigNoz nodes", - "type": "number", - "default": 50 - } - } -} diff --git a/internal/infrastructure/terraform/templates/azure/vm/main.tf.json.gotmpl b/internal/infrastructure/terraform/templates/azure/vm/main.tf.json.gotmpl deleted file mode 100644 index c23f8c69..00000000 --- a/internal/infrastructure/terraform/templates/azure/vm/main.tf.json.gotmpl +++ /dev/null @@ -1,537 +0,0 @@ -{ - "locals": { - "name": "{{ .Metadata.Name }}", - "common_tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "resource": { - "azurerm_resource_group": { - "main": { - "name": "${var.resource_group_name}", - "location": "${var.location}", - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - } - }, - "azurerm_virtual_network": { - "main": { - "name": "${local.name}-vnet", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "address_space": ["${var.vnet_cidr}"], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - } - }, - "azurerm_subnet": { - "private": { - "name": "${local.name}-private", - "resource_group_name": "${azurerm_resource_group.main.name}", - "virtual_network_name": "${azurerm_virtual_network.main.name}", - "address_prefixes": ["${var.private_subnet_cidr}"] - }, - "public": { - "name": "${local.name}-public", - "resource_group_name": "${azurerm_resource_group.main.name}", - "virtual_network_name": "${azurerm_virtual_network.main.name}", - "address_prefixes": ["${var.public_subnet_cidr}"] - } - }, - "azurerm_network_security_group": { - "telemetrykeeper": { - "name": "${local.name}-telemetrykeeper-nsg", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "security_rule": [ - { - "name": "allow-keeper-client", - "priority": 100, - "direction": "Inbound", - "access": "Allow", - "protocol": "Tcp", - "source_port_range": "*", - "destination_port_range": "9181", - "source_address_prefix": "${var.vnet_cidr}", - "destination_address_prefix": "*" - }, - { - "name": "allow-keeper-raft", - "priority": 110, - "direction": "Inbound", - "access": "Allow", - "protocol": "Tcp", - "source_port_range": "*", - "destination_port_range": "9234", - "source_address_prefix": "${var.vnet_cidr}", - "destination_address_prefix": "*" - }, - { - "name": "allow-ssh", - "priority": 120, - "direction": "Inbound", - "access": "Allow", - "protocol": "Tcp", - "source_port_range": "*", - "destination_port_range": "22", - "source_address_prefix": "${var.vnet_cidr}", - "destination_address_prefix": "*" - } - ], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "telemetrystore": { - "name": "${local.name}-telemetrystore-nsg", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "security_rule": [ - { - "name": "allow-clickhouse-native", - "priority": 100, - "direction": "Inbound", - "access": "Allow", - "protocol": "Tcp", - "source_port_range": "*", - "destination_port_range": "9000", - "source_address_prefix": "${var.vnet_cidr}", - "destination_address_prefix": "*" - }, - { - "name": "allow-clickhouse-http", - "priority": 110, - "direction": "Inbound", - "access": "Allow", - "protocol": "Tcp", - "source_port_range": "*", - "destination_port_range": "8123", - "source_address_prefix": "${var.vnet_cidr}", - "destination_address_prefix": "*" - }, - { - "name": "allow-ssh", - "priority": 120, - "direction": "Inbound", - "access": "Allow", - "protocol": "Tcp", - "source_port_range": "*", - "destination_port_range": "22", - "source_address_prefix": "${var.vnet_cidr}", - "destination_address_prefix": "*" - } - ], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "metastore": { - "name": "${local.name}-metastore-nsg", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "security_rule": [ - { - "name": "allow-postgres", - "priority": 100, - "direction": "Inbound", - "access": "Allow", - "protocol": "Tcp", - "source_port_range": "*", - "destination_port_range": "5432", - "source_address_prefix": "${var.vnet_cidr}", - "destination_address_prefix": "*" - }, - { - "name": "allow-ssh", - "priority": 110, - "direction": "Inbound", - "access": "Allow", - "protocol": "Tcp", - "source_port_range": "*", - "destination_port_range": "22", - "source_address_prefix": "${var.vnet_cidr}", - "destination_address_prefix": "*" - } - ], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "ingester": { - "name": "${local.name}-ingester-nsg", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "security_rule": [ - { - "name": "allow-otlp-grpc", - "priority": 100, - "direction": "Inbound", - "access": "Allow", - "protocol": "Tcp", - "source_port_range": "*", - "destination_port_range": "4317", - "source_address_prefix": "*", - "destination_address_prefix": "*" - }, - { - "name": "allow-otlp-http", - "priority": 110, - "direction": "Inbound", - "access": "Allow", - "protocol": "Tcp", - "source_port_range": "*", - "destination_port_range": "4318", - "source_address_prefix": "*", - "destination_address_prefix": "*" - }, - { - "name": "allow-ssh", - "priority": 120, - "direction": "Inbound", - "access": "Allow", - "protocol": "Tcp", - "source_port_range": "*", - "destination_port_range": "22", - "source_address_prefix": "${var.vnet_cidr}", - "destination_address_prefix": "*" - } - ], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "signoz": { - "name": "${local.name}-signoz-nsg", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "security_rule": [ - { - "name": "allow-ui", - "priority": 100, - "direction": "Inbound", - "access": "Allow", - "protocol": "Tcp", - "source_port_range": "*", - "destination_port_range": "8080", - "source_address_prefix": "*", - "destination_address_prefix": "*" - }, - { - "name": "allow-api", - "priority": 110, - "direction": "Inbound", - "access": "Allow", - "protocol": "Tcp", - "source_port_range": "*", - "destination_port_range": "3301", - "source_address_prefix": "${var.vnet_cidr}", - "destination_address_prefix": "*" - }, - { - "name": "allow-ssh", - "priority": 120, - "direction": "Inbound", - "access": "Allow", - "protocol": "Tcp", - "source_port_range": "*", - "destination_port_range": "22", - "source_address_prefix": "${var.vnet_cidr}", - "destination_address_prefix": "*" - } - ], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - } - }, - "azurerm_network_interface": { - "telemetrykeeper": { - "count": {{ if .Spec.TelemetryKeeper.Spec.Enabled }}{{ derefInt .Spec.TelemetryKeeper.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-telemetrykeeper-nic-${count.index}", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "ip_configuration": [{ - "name": "internal", - "subnet_id": "${azurerm_subnet.private.id}", - "private_ip_address_allocation": "Dynamic" - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "telemetrystore": { - "count": {{ if .Spec.TelemetryStore.Spec.Enabled }}{{ derefInt .Spec.TelemetryStore.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-telemetrystore-nic-${count.index}", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "ip_configuration": [{ - "name": "internal", - "subnet_id": "${azurerm_subnet.private.id}", - "private_ip_address_allocation": "Dynamic" - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "metastore": { - "count": {{ if .Spec.MetaStore.Spec.Enabled }}{{ derefInt .Spec.MetaStore.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-metastore-nic-${count.index}", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "ip_configuration": [{ - "name": "internal", - "subnet_id": "${azurerm_subnet.private.id}", - "private_ip_address_allocation": "Dynamic" - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "ingester": { - "count": {{ if .Spec.Ingester.Spec.Enabled }}{{ derefInt .Spec.Ingester.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-ingester-nic-${count.index}", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "ip_configuration": [{ - "name": "external", - "subnet_id": "${azurerm_subnet.public.id}", - "private_ip_address_allocation": "Dynamic", - "public_ip_address_id": "${azurerm_public_ip.ingester[count.index].id}" - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "signoz": { - "count": {{ if .Spec.Signoz.Spec.Enabled }}{{ derefInt .Spec.Signoz.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-signoz-nic-${count.index}", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "ip_configuration": [{ - "name": "external", - "subnet_id": "${azurerm_subnet.public.id}", - "private_ip_address_allocation": "Dynamic", - "public_ip_address_id": "${azurerm_public_ip.signoz[count.index].id}" - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - } - }, - "azurerm_network_interface_security_group_association": { - "telemetrykeeper": { - "count": {{ if .Spec.TelemetryKeeper.Spec.Enabled }}{{ derefInt .Spec.TelemetryKeeper.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "network_interface_id": "${azurerm_network_interface.telemetrykeeper[count.index].id}", - "network_security_group_id": "${azurerm_network_security_group.telemetrykeeper.id}" - }, - "telemetrystore": { - "count": {{ if .Spec.TelemetryStore.Spec.Enabled }}{{ derefInt .Spec.TelemetryStore.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "network_interface_id": "${azurerm_network_interface.telemetrystore[count.index].id}", - "network_security_group_id": "${azurerm_network_security_group.telemetrystore.id}" - }, - "metastore": { - "count": {{ if .Spec.MetaStore.Spec.Enabled }}{{ derefInt .Spec.MetaStore.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "network_interface_id": "${azurerm_network_interface.metastore[count.index].id}", - "network_security_group_id": "${azurerm_network_security_group.metastore.id}" - }, - "ingester": { - "count": {{ if .Spec.Ingester.Spec.Enabled }}{{ derefInt .Spec.Ingester.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "network_interface_id": "${azurerm_network_interface.ingester[count.index].id}", - "network_security_group_id": "${azurerm_network_security_group.ingester.id}" - }, - "signoz": { - "count": {{ if .Spec.Signoz.Spec.Enabled }}{{ derefInt .Spec.Signoz.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "network_interface_id": "${azurerm_network_interface.signoz[count.index].id}", - "network_security_group_id": "${azurerm_network_security_group.signoz.id}" - } - }, - "azurerm_public_ip": { - "ingester": { - "count": {{ if .Spec.Ingester.Spec.Enabled }}{{ derefInt .Spec.Ingester.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-ingester-pip-${count.index}", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "allocation_method": "Static", - "sku": "Standard", - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - }, - "signoz": { - "count": {{ if .Spec.Signoz.Spec.Enabled }}{{ derefInt .Spec.Signoz.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-signoz-pip-${count.index}", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "allocation_method": "Static", - "sku": "Standard", - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}" - } - } - }, - "azurerm_linux_virtual_machine": { - "telemetrykeeper": { - "count": {{ if .Spec.TelemetryKeeper.Spec.Enabled }}{{ derefInt .Spec.TelemetryKeeper.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-telemetrykeeper-${count.index}", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "size": "${var.telemetrykeeper_vm_size}", - "admin_username": "ubuntu", - "network_interface_ids": ["${azurerm_network_interface.telemetrykeeper[count.index].id}"], - "admin_ssh_key": [{ - "username": "ubuntu", - "public_key": "${var.ssh_public_key}" - }], - "os_disk": [{ - "caching": "ReadWrite", - "storage_account_type": "Premium_LRS", - "disk_size_gb": "${var.telemetrykeeper_disk_size}" - }], - "source_image_reference": [{ - "publisher": "Canonical", - "offer": "0001-com-ubuntu-server-jammy", - "sku": "22_04-lts", - "version": "latest" - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Role": "telemetrykeeper" - } - }, - "telemetrystore": { - "count": {{ if .Spec.TelemetryStore.Spec.Enabled }}{{ derefInt .Spec.TelemetryStore.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-telemetrystore-${count.index}", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "size": "${var.telemetrystore_vm_size}", - "admin_username": "ubuntu", - "network_interface_ids": ["${azurerm_network_interface.telemetrystore[count.index].id}"], - "admin_ssh_key": [{ - "username": "ubuntu", - "public_key": "${var.ssh_public_key}" - }], - "os_disk": [{ - "caching": "ReadWrite", - "storage_account_type": "Premium_LRS", - "disk_size_gb": "${var.telemetrystore_disk_size}" - }], - "source_image_reference": [{ - "publisher": "Canonical", - "offer": "0001-com-ubuntu-server-jammy", - "sku": "22_04-lts", - "version": "latest" - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Role": "telemetrystore" - } - }, - "metastore": { - "count": {{ if .Spec.MetaStore.Spec.Enabled }}{{ derefInt .Spec.MetaStore.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-metastore-${count.index}", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "size": "${var.metastore_vm_size}", - "admin_username": "ubuntu", - "network_interface_ids": ["${azurerm_network_interface.metastore[count.index].id}"], - "admin_ssh_key": [{ - "username": "ubuntu", - "public_key": "${var.ssh_public_key}" - }], - "os_disk": [{ - "caching": "ReadWrite", - "storage_account_type": "Premium_LRS", - "disk_size_gb": "${var.metastore_disk_size}" - }], - "source_image_reference": [{ - "publisher": "Canonical", - "offer": "0001-com-ubuntu-server-jammy", - "sku": "22_04-lts", - "version": "latest" - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Role": "metastore" - } - }, - "ingester": { - "count": {{ if .Spec.Ingester.Spec.Enabled }}{{ derefInt .Spec.Ingester.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-ingester-${count.index}", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "size": "${var.ingester_vm_size}", - "admin_username": "ubuntu", - "network_interface_ids": ["${azurerm_network_interface.ingester[count.index].id}"], - "admin_ssh_key": [{ - "username": "ubuntu", - "public_key": "${var.ssh_public_key}" - }], - "os_disk": [{ - "caching": "ReadWrite", - "storage_account_type": "Premium_LRS", - "disk_size_gb": "${var.ingester_disk_size}" - }], - "source_image_reference": [{ - "publisher": "Canonical", - "offer": "0001-com-ubuntu-server-jammy", - "sku": "22_04-lts", - "version": "latest" - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Role": "ingester" - } - }, - "signoz": { - "count": {{ if .Spec.Signoz.Spec.Enabled }}{{ derefInt .Spec.Signoz.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-signoz-${count.index}", - "location": "${azurerm_resource_group.main.location}", - "resource_group_name": "${azurerm_resource_group.main.name}", - "size": "${var.signoz_vm_size}", - "admin_username": "ubuntu", - "network_interface_ids": ["${azurerm_network_interface.signoz[count.index].id}"], - "admin_ssh_key": [{ - "username": "ubuntu", - "public_key": "${var.ssh_public_key}" - }], - "os_disk": [{ - "caching": "ReadWrite", - "storage_account_type": "Premium_LRS", - "disk_size_gb": "${var.signoz_disk_size}" - }], - "source_image_reference": [{ - "publisher": "Canonical", - "offer": "0001-com-ubuntu-server-jammy", - "sku": "22_04-lts", - "version": "latest" - }], - "tags": { - "app.kubernetes.io/managed-by": "foundry", - "app.kubernetes.io/name": "{{ .Metadata.Name }}", - "Role": "signoz" - } - } - } - } -} diff --git a/internal/infrastructure/terraform/templates/azure/vm/outputs.tf.json.gotmpl b/internal/infrastructure/terraform/templates/azure/vm/outputs.tf.json.gotmpl deleted file mode 100644 index 152e95aa..00000000 --- a/internal/infrastructure/terraform/templates/azure/vm/outputs.tf.json.gotmpl +++ /dev/null @@ -1,68 +0,0 @@ -{ - "output": { - "resource_group_name": { - "description": "Name of the Azure resource group", - "value": "${azurerm_resource_group.main.name}" - }, - "vnet_id": { - "description": "ID of the virtual network", - "value": "${azurerm_virtual_network.main.id}" - }, - "private_subnet_id": { - "description": "ID of the private subnet", - "value": "${azurerm_subnet.private.id}" - }, - "public_subnet_id": { - "description": "ID of the public subnet", - "value": "${azurerm_subnet.public.id}" - }, - "telemetrykeeper_vm_ids": { - "description": "IDs of the TelemetryKeeper VMs", - "value": "${azurerm_linux_virtual_machine.telemetrykeeper[*].id}" - }, - "telemetrykeeper_private_ips": { - "description": "Private IP addresses of the TelemetryKeeper VMs", - "value": "${azurerm_network_interface.telemetrykeeper[*].private_ip_address}" - }, - "telemetrystore_vm_ids": { - "description": "IDs of the TelemetryStore VMs", - "value": "${azurerm_linux_virtual_machine.telemetrystore[*].id}" - }, - "telemetrystore_private_ips": { - "description": "Private IP addresses of the TelemetryStore VMs", - "value": "${azurerm_network_interface.telemetrystore[*].private_ip_address}" - }, - "metastore_vm_ids": { - "description": "IDs of the MetaStore VMs", - "value": "${azurerm_linux_virtual_machine.metastore[*].id}" - }, - "metastore_private_ips": { - "description": "Private IP addresses of the MetaStore VMs", - "value": "${azurerm_network_interface.metastore[*].private_ip_address}" - }, - "ingester_vm_ids": { - "description": "IDs of the Ingester VMs", - "value": "${azurerm_linux_virtual_machine.ingester[*].id}" - }, - "ingester_public_ips": { - "description": "Public IP addresses of the Ingester VMs", - "value": "${azurerm_public_ip.ingester[*].ip_address}" - }, - "ingester_private_ips": { - "description": "Private IP addresses of the Ingester VMs", - "value": "${azurerm_network_interface.ingester[*].private_ip_address}" - }, - "signoz_vm_ids": { - "description": "IDs of the SigNoz VMs", - "value": "${azurerm_linux_virtual_machine.signoz[*].id}" - }, - "signoz_public_ips": { - "description": "Public IP addresses of the SigNoz VMs", - "value": "${azurerm_public_ip.signoz[*].ip_address}" - }, - "signoz_private_ips": { - "description": "Private IP addresses of the SigNoz VMs", - "value": "${azurerm_network_interface.signoz[*].private_ip_address}" - } - } -} diff --git a/internal/infrastructure/terraform/templates/azure/vm/variables.tf.json.gotmpl b/internal/infrastructure/terraform/templates/azure/vm/variables.tf.json.gotmpl deleted file mode 100644 index 53267376..00000000 --- a/internal/infrastructure/terraform/templates/azure/vm/variables.tf.json.gotmpl +++ /dev/null @@ -1,87 +0,0 @@ -{ - "variable": { - "resource_group_name": { - "description": "Azure resource group name", - "type": "string" - }, - "location": { - "description": "Azure region to deploy resources", - "type": "string", - "default": "eastus" - }, - "name": { - "description": "The name of the deployment", - "type": "string", - "default": "{{ .Metadata.Name }}" - }, - "vnet_cidr": { - "description": "CIDR block for the virtual network", - "type": "string", - "default": "10.0.0.0/16" - }, - "private_subnet_cidr": { - "description": "CIDR block for the private subnet", - "type": "string", - "default": "10.0.1.0/24" - }, - "public_subnet_cidr": { - "description": "CIDR block for the public subnet", - "type": "string", - "default": "10.0.2.0/24" - }, - "ssh_public_key": { - "description": "SSH public key for VM access", - "type": "string" - }, - "telemetrykeeper_vm_size": { - "description": "Azure VM size for TelemetryKeeper", - "type": "string", - "default": "Standard_B2s" - }, - "telemetrykeeper_disk_size": { - "description": "OS disk size (GB) for TelemetryKeeper VMs", - "type": "number", - "default": 20 - }, - "telemetrystore_vm_size": { - "description": "Azure VM size for TelemetryStore", - "type": "string", - "default": "Standard_E4s_v3" - }, - "telemetrystore_disk_size": { - "description": "OS disk size (GB) for TelemetryStore VMs", - "type": "number", - "default": 100 - }, - "metastore_vm_size": { - "description": "Azure VM size for MetaStore", - "type": "string", - "default": "Standard_B2s" - }, - "metastore_disk_size": { - "description": "OS disk size (GB) for MetaStore VMs", - "type": "number", - "default": 20 - }, - "ingester_vm_size": { - "description": "Azure VM size for Ingester", - "type": "string", - "default": "Standard_B4ms" - }, - "ingester_disk_size": { - "description": "OS disk size (GB) for Ingester VMs", - "type": "number", - "default": 50 - }, - "signoz_vm_size": { - "description": "Azure VM size for SigNoz", - "type": "string", - "default": "Standard_B4ms" - }, - "signoz_disk_size": { - "description": "OS disk size (GB) for SigNoz VMs", - "type": "number", - "default": 50 - } - } -} diff --git a/internal/infrastructure/terraform/templates/gcp/gce/main.tf.json.gotmpl b/internal/infrastructure/terraform/templates/gcp/gce/main.tf.json.gotmpl deleted file mode 100644 index b0f52724..00000000 --- a/internal/infrastructure/terraform/templates/gcp/gce/main.tf.json.gotmpl +++ /dev/null @@ -1,261 +0,0 @@ -{ - "locals": { - "name": "{{ .Metadata.Name }}", - "common_labels": { - "app-kubernetes-io-managed-by": "foundry", - "app-kubernetes-io-name": "{{ .Metadata.Name }}" - } - }, - "data": { - "google_compute_image": { - "ubuntu": { - "family": "ubuntu-2204-lts", - "project": "ubuntu-os-cloud" - } - } - }, - "resource": { - "google_compute_network": { - "main": { - "name": "${local.name}-vpc", - "auto_create_subnetworks": false, - "project": "${var.project_id}" - } - }, - "google_compute_subnetwork": { - "private": { - "name": "${local.name}-private", - "ip_cidr_range": "${var.private_subnet_cidr}", - "region": "${var.region}", - "network": "${google_compute_network.main.id}", - "project": "${var.project_id}", - "private_ip_google_access": true - }, - "public": { - "name": "${local.name}-public", - "ip_cidr_range": "${var.public_subnet_cidr}", - "region": "${var.region}", - "network": "${google_compute_network.main.id}", - "project": "${var.project_id}" - } - }, - "google_compute_router": { - "main": { - "name": "${local.name}-router", - "region": "${var.region}", - "network": "${google_compute_network.main.id}", - "project": "${var.project_id}" - } - }, - "google_compute_router_nat": { - "main": { - "name": "${local.name}-nat", - "router": "${google_compute_router.main.name}", - "region": "${var.region}", - "project": "${var.project_id}", - "nat_ip_allocate_option": "AUTO_ONLY", - "source_subnetwork_ip_ranges_to_nat": "LIST_OF_SUBNETWORKS", - "subnetwork": [{ - "name": "${google_compute_subnetwork.private.id}", - "source_ip_ranges_to_nat": ["ALL_IP_RANGES"] - }] - } - }, - "google_compute_firewall": { - "telemetrykeeper": { - "name": "${local.name}-telemetrykeeper", - "network": "${google_compute_network.main.id}", - "project": "${var.project_id}", - "allow": [ - {"protocol": "tcp", "ports": ["9181", "9234"]}, - {"protocol": "tcp", "ports": ["22"]} - ], - "source_ranges": ["${var.private_subnet_cidr}"], - "target_tags": ["telemetrykeeper"], - "description": "Allow TelemetryKeeper (ClickHouse Keeper) traffic" - }, - "telemetrystore": { - "name": "${local.name}-telemetrystore", - "network": "${google_compute_network.main.id}", - "project": "${var.project_id}", - "allow": [ - {"protocol": "tcp", "ports": ["9000", "8123"]}, - {"protocol": "tcp", "ports": ["22"]} - ], - "source_ranges": ["${var.private_subnet_cidr}"], - "target_tags": ["telemetrystore"], - "description": "Allow TelemetryStore (ClickHouse) traffic" - }, - "metastore": { - "name": "${local.name}-metastore", - "network": "${google_compute_network.main.id}", - "project": "${var.project_id}", - "allow": [ - {"protocol": "tcp", "ports": ["5432"]}, - {"protocol": "tcp", "ports": ["22"]} - ], - "source_ranges": ["${var.private_subnet_cidr}"], - "target_tags": ["metastore"], - "description": "Allow MetaStore (PostgreSQL) traffic" - }, - "ingester": { - "name": "${local.name}-ingester", - "network": "${google_compute_network.main.id}", - "project": "${var.project_id}", - "allow": [ - {"protocol": "tcp", "ports": ["4317", "4318"]}, - {"protocol": "tcp", "ports": ["22"]} - ], - "source_ranges": ["0.0.0.0/0"], - "target_tags": ["ingester"], - "description": "Allow Ingester (OpenTelemetry Collector) traffic" - }, - "signoz": { - "name": "${local.name}-signoz", - "network": "${google_compute_network.main.id}", - "project": "${var.project_id}", - "allow": [ - {"protocol": "tcp", "ports": ["8080", "3301"]}, - {"protocol": "tcp", "ports": ["22"]} - ], - "source_ranges": ["0.0.0.0/0"], - "target_tags": ["signoz"], - "description": "Allow SigNoz UI and API traffic" - } - }, - "google_compute_instance": { - "telemetrykeeper": { - "count": {{ if .Spec.TelemetryKeeper.Spec.Enabled }}{{ derefInt .Spec.TelemetryKeeper.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-telemetrykeeper-${count.index}", - "machine_type": "${var.telemetrykeeper_machine_type}", - "zone": "${var.region}-${element([\"a\", \"b\", \"c\"], count.index)}", - "project": "${var.project_id}", - "tags": ["telemetrykeeper"], - "boot_disk": [{ - "initialize_params": [{ - "image": "${data.google_compute_image.ubuntu.self_link}", - "size": "${var.telemetrykeeper_disk_size}", - "type": "pd-ssd" - }] - }], - "network_interface": [{ - "subnetwork": "${google_compute_subnetwork.private.id}" - }], - "labels": { - "app-kubernetes-io-managed-by": "foundry", - "app-kubernetes-io-name": "{{ .Metadata.Name }}", - "role": "telemetrykeeper" - }, - "metadata": { - "enable-oslogin": "TRUE" - } - }, - "telemetrystore": { - "count": {{ if .Spec.TelemetryStore.Spec.Enabled }}{{ derefInt .Spec.TelemetryStore.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-telemetrystore-${count.index}", - "machine_type": "${var.telemetrystore_machine_type}", - "zone": "${var.region}-${element([\"a\", \"b\", \"c\"], count.index)}", - "project": "${var.project_id}", - "tags": ["telemetrystore"], - "boot_disk": [{ - "initialize_params": [{ - "image": "${data.google_compute_image.ubuntu.self_link}", - "size": "${var.telemetrystore_disk_size}", - "type": "pd-ssd" - }] - }], - "network_interface": [{ - "subnetwork": "${google_compute_subnetwork.private.id}" - }], - "labels": { - "app-kubernetes-io-managed-by": "foundry", - "app-kubernetes-io-name": "{{ .Metadata.Name }}", - "role": "telemetrystore" - }, - "metadata": { - "enable-oslogin": "TRUE" - } - }, - "metastore": { - "count": {{ if .Spec.MetaStore.Spec.Enabled }}{{ derefInt .Spec.MetaStore.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-metastore-${count.index}", - "machine_type": "${var.metastore_machine_type}", - "zone": "${var.region}-${element([\"a\", \"b\", \"c\"], count.index)}", - "project": "${var.project_id}", - "tags": ["metastore"], - "boot_disk": [{ - "initialize_params": [{ - "image": "${data.google_compute_image.ubuntu.self_link}", - "size": "${var.metastore_disk_size}", - "type": "pd-ssd" - }] - }], - "network_interface": [{ - "subnetwork": "${google_compute_subnetwork.private.id}" - }], - "labels": { - "app-kubernetes-io-managed-by": "foundry", - "app-kubernetes-io-name": "{{ .Metadata.Name }}", - "role": "metastore" - }, - "metadata": { - "enable-oslogin": "TRUE" - } - }, - "ingester": { - "count": {{ if .Spec.Ingester.Spec.Enabled }}{{ derefInt .Spec.Ingester.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-ingester-${count.index}", - "machine_type": "${var.ingester_machine_type}", - "zone": "${var.region}-${element([\"a\", \"b\", \"c\"], count.index)}", - "project": "${var.project_id}", - "tags": ["ingester"], - "boot_disk": [{ - "initialize_params": [{ - "image": "${data.google_compute_image.ubuntu.self_link}", - "size": "${var.ingester_disk_size}", - "type": "pd-ssd" - }] - }], - "network_interface": [{ - "subnetwork": "${google_compute_subnetwork.public.id}", - "access_config": [{}] - }], - "labels": { - "app-kubernetes-io-managed-by": "foundry", - "app-kubernetes-io-name": "{{ .Metadata.Name }}", - "role": "ingester" - }, - "metadata": { - "enable-oslogin": "TRUE" - } - }, - "signoz": { - "count": {{ if .Spec.Signoz.Spec.Enabled }}{{ derefInt .Spec.Signoz.Spec.Cluster.Replicas }}{{ else }}0{{ end }}, - "name": "${local.name}-signoz-${count.index}", - "machine_type": "${var.signoz_machine_type}", - "zone": "${var.region}-${element([\"a\", \"b\", \"c\"], count.index)}", - "project": "${var.project_id}", - "tags": ["signoz"], - "boot_disk": [{ - "initialize_params": [{ - "image": "${data.google_compute_image.ubuntu.self_link}", - "size": "${var.signoz_disk_size}", - "type": "pd-ssd" - }] - }], - "network_interface": [{ - "subnetwork": "${google_compute_subnetwork.public.id}", - "access_config": [{}] - }], - "labels": { - "app-kubernetes-io-managed-by": "foundry", - "app-kubernetes-io-name": "{{ .Metadata.Name }}", - "role": "signoz" - }, - "metadata": { - "enable-oslogin": "TRUE" - } - } - } - } -} diff --git a/internal/infrastructure/terraform/templates/gcp/gce/outputs.tf.json.gotmpl b/internal/infrastructure/terraform/templates/gcp/gce/outputs.tf.json.gotmpl deleted file mode 100644 index abe80056..00000000 --- a/internal/infrastructure/terraform/templates/gcp/gce/outputs.tf.json.gotmpl +++ /dev/null @@ -1,64 +0,0 @@ -{ - "output": { - "network_id": { - "description": "ID of the VPC network", - "value": "${google_compute_network.main.id}" - }, - "private_subnetwork_id": { - "description": "ID of the private subnetwork", - "value": "${google_compute_subnetwork.private.id}" - }, - "public_subnetwork_id": { - "description": "ID of the public subnetwork", - "value": "${google_compute_subnetwork.public.id}" - }, - "telemetrykeeper_instance_ids": { - "description": "IDs of the TelemetryKeeper GCE instances", - "value": "${google_compute_instance.telemetrykeeper[*].id}" - }, - "telemetrykeeper_private_ips": { - "description": "Private IP addresses of the TelemetryKeeper GCE instances", - "value": "${[for i in google_compute_instance.telemetrykeeper : i.network_interface[0].network_ip]}" - }, - "telemetrystore_instance_ids": { - "description": "IDs of the TelemetryStore GCE instances", - "value": "${google_compute_instance.telemetrystore[*].id}" - }, - "telemetrystore_private_ips": { - "description": "Private IP addresses of the TelemetryStore GCE instances", - "value": "${[for i in google_compute_instance.telemetrystore : i.network_interface[0].network_ip]}" - }, - "metastore_instance_ids": { - "description": "IDs of the MetaStore GCE instances", - "value": "${google_compute_instance.metastore[*].id}" - }, - "metastore_private_ips": { - "description": "Private IP addresses of the MetaStore GCE instances", - "value": "${[for i in google_compute_instance.metastore : i.network_interface[0].network_ip]}" - }, - "ingester_instance_ids": { - "description": "IDs of the Ingester GCE instances", - "value": "${google_compute_instance.ingester[*].id}" - }, - "ingester_public_ips": { - "description": "Public IP addresses of the Ingester GCE instances", - "value": "${[for i in google_compute_instance.ingester : i.network_interface[0].access_config[0].nat_ip]}" - }, - "ingester_private_ips": { - "description": "Private IP addresses of the Ingester GCE instances", - "value": "${[for i in google_compute_instance.ingester : i.network_interface[0].network_ip]}" - }, - "signoz_instance_ids": { - "description": "IDs of the SigNoz GCE instances", - "value": "${google_compute_instance.signoz[*].id}" - }, - "signoz_public_ips": { - "description": "Public IP addresses of the SigNoz GCE instances", - "value": "${[for i in google_compute_instance.signoz : i.network_interface[0].access_config[0].nat_ip]}" - }, - "signoz_private_ips": { - "description": "Private IP addresses of the SigNoz GCE instances", - "value": "${[for i in google_compute_instance.signoz : i.network_interface[0].network_ip]}" - } - } -} diff --git a/internal/infrastructure/terraform/templates/gcp/gce/variables.tf.json.gotmpl b/internal/infrastructure/terraform/templates/gcp/gce/variables.tf.json.gotmpl deleted file mode 100644 index c94e3079..00000000 --- a/internal/infrastructure/terraform/templates/gcp/gce/variables.tf.json.gotmpl +++ /dev/null @@ -1,78 +0,0 @@ -{ - "variable": { - "project_id": { - "description": "GCP project ID", - "type": "string" - }, - "region": { - "description": "GCP region to deploy resources", - "type": "string", - "default": "us-central1" - }, - "name": { - "description": "The name of the deployment", - "type": "string", - "default": "{{ .Metadata.Name }}" - }, - "private_subnet_cidr": { - "description": "CIDR block for the private subnet", - "type": "string", - "default": "10.0.1.0/24" - }, - "public_subnet_cidr": { - "description": "CIDR block for the public subnet", - "type": "string", - "default": "10.0.2.0/24" - }, - "telemetrykeeper_machine_type": { - "description": "GCE machine type for TelemetryKeeper", - "type": "string", - "default": "n2-standard-2" - }, - "telemetrykeeper_disk_size": { - "description": "Boot disk size (GB) for TelemetryKeeper instances", - "type": "number", - "default": 20 - }, - "telemetrystore_machine_type": { - "description": "GCE machine type for TelemetryStore", - "type": "string", - "default": "n2-highmem-4" - }, - "telemetrystore_disk_size": { - "description": "Boot disk size (GB) for TelemetryStore instances", - "type": "number", - "default": 100 - }, - "metastore_machine_type": { - "description": "GCE machine type for MetaStore", - "type": "string", - "default": "n2-standard-2" - }, - "metastore_disk_size": { - "description": "Boot disk size (GB) for MetaStore instances", - "type": "number", - "default": 20 - }, - "ingester_machine_type": { - "description": "GCE machine type for Ingester", - "type": "string", - "default": "n2-standard-4" - }, - "ingester_disk_size": { - "description": "Boot disk size (GB) for Ingester instances", - "type": "number", - "default": 50 - }, - "signoz_machine_type": { - "description": "GCE machine type for SigNoz", - "type": "string", - "default": "n2-standard-4" - }, - "signoz_disk_size": { - "description": "Boot disk size (GB) for SigNoz instances", - "type": "number", - "default": 50 - } - } -} diff --git a/internal/infrastructure/terraform/templates/gcp/gke/main.tf.json.gotmpl b/internal/infrastructure/terraform/templates/gcp/gke/main.tf.json.gotmpl deleted file mode 100644 index 9a4a60d7..00000000 --- a/internal/infrastructure/terraform/templates/gcp/gke/main.tf.json.gotmpl +++ /dev/null @@ -1,224 +0,0 @@ -{ - "locals": { - "name": "{{ .Metadata.Name }}", - "common_labels": { - "app-kubernetes-io-managed-by": "foundry", - "app-kubernetes-io-name": "{{ .Metadata.Name }}" - } - }, - "resource": { - "google_compute_network": { - "main": { - "name": "${local.name}-vpc", - "auto_create_subnetworks": false, - "project": "${var.project_id}" - } - }, - "google_compute_subnetwork": { - "private": { - "name": "${local.name}-private", - "ip_cidr_range": "${var.private_subnet_cidr}", - "region": "${var.region}", - "network": "${google_compute_network.main.id}", - "project": "${var.project_id}", - "private_ip_google_access": true, - "secondary_ip_range": [ - { - "range_name": "${local.name}-pods", - "ip_cidr_range": "${var.pods_cidr}" - }, - { - "range_name": "${local.name}-services", - "ip_cidr_range": "${var.services_cidr}" - } - ] - }, - "public": { - "name": "${local.name}-public", - "ip_cidr_range": "${var.public_subnet_cidr}", - "region": "${var.region}", - "network": "${google_compute_network.main.id}", - "project": "${var.project_id}" - } - }, - "google_compute_router": { - "main": { - "name": "${local.name}-router", - "region": "${var.region}", - "network": "${google_compute_network.main.id}", - "project": "${var.project_id}" - } - }, - "google_compute_router_nat": { - "main": { - "name": "${local.name}-nat", - "router": "${google_compute_router.main.name}", - "region": "${var.region}", - "project": "${var.project_id}", - "nat_ip_allocate_option": "AUTO_ONLY", - "source_subnetwork_ip_ranges_to_nat": "LIST_OF_SUBNETWORKS", - "subnetwork": [{ - "name": "${google_compute_subnetwork.private.id}", - "source_ip_ranges_to_nat": ["ALL_IP_RANGES"] - }] - } - }, - "google_container_cluster": { - "main": { - "name": "${local.name}", - "location": "${var.region}", - "project": "${var.project_id}", - "network": "${google_compute_network.main.id}", - "subnetwork": "${google_compute_subnetwork.private.id}", - "remove_default_node_pool": true, - "initial_node_count": 1, - "ip_allocation_policy": [{ - "cluster_secondary_range_name": "${local.name}-pods", - "services_secondary_range_name": "${local.name}-services" - }], - "private_cluster_config": [{ - "enable_private_nodes": true, - "enable_private_endpoint": false, - "master_ipv4_cidr_block": "${var.master_cidr}" - }], - "master_auth": [{ - "client_certificate_config": [{ - "issue_client_certificate": false - }] - }], - "workload_identity_config": [{ - "workload_pool": "${var.project_id}.svc.id.goog" - }], - "release_channel": [{ - "channel": "REGULAR" - }], - "resource_labels": { - "app-kubernetes-io-managed-by": "foundry", - "app-kubernetes-io-name": "{{ .Metadata.Name }}" - } - } - }, - "google_container_node_pool": { - "telemetrykeeper": { - "count": {{ if .Spec.TelemetryKeeper.Spec.Enabled }}1{{ else }}0{{ end }}, - "name": "${local.name}-telemetrykeeper", - "cluster": "${google_container_cluster.main.name}", - "location": "${var.region}", - "project": "${var.project_id}", - "node_count": {{ derefInt .Spec.TelemetryKeeper.Spec.Cluster.Replicas }}, - "node_config": [{ - "machine_type": "${var.telemetrykeeper_machine_type}", - "disk_size_gb": "${var.telemetrykeeper_disk_size}", - "disk_type": "pd-ssd", - "labels": { - "app-kubernetes-io-managed-by": "foundry", - "app-kubernetes-io-name": "{{ .Metadata.Name }}", - "role": "telemetrykeeper" - }, - "workload_metadata_config": [{"mode": "GKE_METADATA"}], - "shielded_instance_config": [{"enable_secure_boot": true}] - }], - "management": [{ - "auto_repair": true, - "auto_upgrade": true - }] - }, - "telemetrystore": { - "count": {{ if .Spec.TelemetryStore.Spec.Enabled }}1{{ else }}0{{ end }}, - "name": "${local.name}-telemetrystore", - "cluster": "${google_container_cluster.main.name}", - "location": "${var.region}", - "project": "${var.project_id}", - "node_count": {{ derefInt .Spec.TelemetryStore.Spec.Cluster.Replicas }}, - "node_config": [{ - "machine_type": "${var.telemetrystore_machine_type}", - "disk_size_gb": "${var.telemetrystore_disk_size}", - "disk_type": "pd-ssd", - "labels": { - "app-kubernetes-io-managed-by": "foundry", - "app-kubernetes-io-name": "{{ .Metadata.Name }}", - "role": "telemetrystore" - }, - "workload_metadata_config": [{"mode": "GKE_METADATA"}], - "shielded_instance_config": [{"enable_secure_boot": true}] - }], - "management": [{ - "auto_repair": true, - "auto_upgrade": true - }] - }, - "metastore": { - "count": {{ if .Spec.MetaStore.Spec.Enabled }}1{{ else }}0{{ end }}, - "name": "${local.name}-metastore", - "cluster": "${google_container_cluster.main.name}", - "location": "${var.region}", - "project": "${var.project_id}", - "node_count": {{ derefInt .Spec.MetaStore.Spec.Cluster.Replicas }}, - "node_config": [{ - "machine_type": "${var.metastore_machine_type}", - "disk_size_gb": "${var.metastore_disk_size}", - "disk_type": "pd-ssd", - "labels": { - "app-kubernetes-io-managed-by": "foundry", - "app-kubernetes-io-name": "{{ .Metadata.Name }}", - "role": "metastore" - }, - "workload_metadata_config": [{"mode": "GKE_METADATA"}], - "shielded_instance_config": [{"enable_secure_boot": true}] - }], - "management": [{ - "auto_repair": true, - "auto_upgrade": true - }] - }, - "ingester": { - "count": {{ if .Spec.Ingester.Spec.Enabled }}1{{ else }}0{{ end }}, - "name": "${local.name}-ingester", - "cluster": "${google_container_cluster.main.name}", - "location": "${var.region}", - "project": "${var.project_id}", - "node_count": {{ derefInt .Spec.Ingester.Spec.Cluster.Replicas }}, - "node_config": [{ - "machine_type": "${var.ingester_machine_type}", - "disk_size_gb": "${var.ingester_disk_size}", - "disk_type": "pd-ssd", - "labels": { - "app-kubernetes-io-managed-by": "foundry", - "app-kubernetes-io-name": "{{ .Metadata.Name }}", - "role": "ingester" - }, - "workload_metadata_config": [{"mode": "GKE_METADATA"}], - "shielded_instance_config": [{"enable_secure_boot": true}] - }], - "management": [{ - "auto_repair": true, - "auto_upgrade": true - }] - }, - "signoz": { - "count": {{ if .Spec.Signoz.Spec.Enabled }}1{{ else }}0{{ end }}, - "name": "${local.name}-signoz", - "cluster": "${google_container_cluster.main.name}", - "location": "${var.region}", - "project": "${var.project_id}", - "node_count": {{ derefInt .Spec.Signoz.Spec.Cluster.Replicas }}, - "node_config": [{ - "machine_type": "${var.signoz_machine_type}", - "disk_size_gb": "${var.signoz_disk_size}", - "disk_type": "pd-ssd", - "labels": { - "app-kubernetes-io-managed-by": "foundry", - "app-kubernetes-io-name": "{{ .Metadata.Name }}", - "role": "signoz" - }, - "workload_metadata_config": [{"mode": "GKE_METADATA"}], - "shielded_instance_config": [{"enable_secure_boot": true}] - }], - "management": [{ - "auto_repair": true, - "auto_upgrade": true - }] - } - } - } -} diff --git a/internal/infrastructure/terraform/templates/gcp/gke/outputs.tf.json.gotmpl b/internal/infrastructure/terraform/templates/gcp/gke/outputs.tf.json.gotmpl deleted file mode 100644 index e68728ae..00000000 --- a/internal/infrastructure/terraform/templates/gcp/gke/outputs.tf.json.gotmpl +++ /dev/null @@ -1,46 +0,0 @@ -{ - "output": { - "cluster_name": { - "description": "Name of the GKE cluster", - "value": "${google_container_cluster.main.name}" - }, - "cluster_endpoint": { - "description": "Endpoint for the GKE cluster master", - "value": "${google_container_cluster.main.endpoint}", - "sensitive": true - }, - "cluster_ca_certificate": { - "description": "Base64-encoded public certificate of the cluster's certificate authority", - "value": "${google_container_cluster.main.master_auth[0].cluster_ca_certificate}", - "sensitive": true - }, - "network_id": { - "description": "ID of the VPC network", - "value": "${google_compute_network.main.id}" - }, - "private_subnetwork_id": { - "description": "ID of the private subnetwork", - "value": "${google_compute_subnetwork.private.id}" - }, - "telemetrykeeper_node_pool_id": { - "description": "ID of the TelemetryKeeper node pool", - "value": "${google_container_node_pool.telemetrykeeper[*].id}" - }, - "telemetrystore_node_pool_id": { - "description": "ID of the TelemetryStore node pool", - "value": "${google_container_node_pool.telemetrystore[*].id}" - }, - "metastore_node_pool_id": { - "description": "ID of the MetaStore node pool", - "value": "${google_container_node_pool.metastore[*].id}" - }, - "ingester_node_pool_id": { - "description": "ID of the Ingester node pool", - "value": "${google_container_node_pool.ingester[*].id}" - }, - "signoz_node_pool_id": { - "description": "ID of the SigNoz node pool", - "value": "${google_container_node_pool.signoz[*].id}" - } - } -} diff --git a/internal/infrastructure/terraform/templates/gcp/gke/variables.tf.json.gotmpl b/internal/infrastructure/terraform/templates/gcp/gke/variables.tf.json.gotmpl deleted file mode 100644 index 6138d3cd..00000000 --- a/internal/infrastructure/terraform/templates/gcp/gke/variables.tf.json.gotmpl +++ /dev/null @@ -1,98 +0,0 @@ -{ - "variable": { - "project_id": { - "description": "GCP project ID", - "type": "string" - }, - "region": { - "description": "GCP region to deploy resources", - "type": "string", - "default": "us-central1" - }, - "name": { - "description": "The name of the deployment", - "type": "string", - "default": "{{ .Metadata.Name }}" - }, - "private_subnet_cidr": { - "description": "CIDR block for the private subnet", - "type": "string", - "default": "10.0.1.0/24" - }, - "public_subnet_cidr": { - "description": "CIDR block for the public subnet", - "type": "string", - "default": "10.0.2.0/24" - }, - "pods_cidr": { - "description": "Secondary CIDR block for GKE pods", - "type": "string", - "default": "10.1.0.0/16" - }, - "services_cidr": { - "description": "Secondary CIDR block for GKE services", - "type": "string", - "default": "10.2.0.0/20" - }, - "master_cidr": { - "description": "CIDR block for the GKE master nodes (must be /28)", - "type": "string", - "default": "172.16.0.0/28" - }, - "kubernetes_version": { - "description": "Minimum Kubernetes version for the GKE cluster (leave empty for latest)", - "type": "string", - "default": "" - }, - "telemetrykeeper_machine_type": { - "description": "GCE machine type for TelemetryKeeper node pool", - "type": "string", - "default": "n2-standard-2" - }, - "telemetrykeeper_disk_size": { - "description": "Boot disk size (GB) for TelemetryKeeper nodes", - "type": "number", - "default": 20 - }, - "telemetrystore_machine_type": { - "description": "GCE machine type for TelemetryStore node pool", - "type": "string", - "default": "n2-highmem-4" - }, - "telemetrystore_disk_size": { - "description": "Boot disk size (GB) for TelemetryStore nodes", - "type": "number", - "default": 100 - }, - "metastore_machine_type": { - "description": "GCE machine type for MetaStore node pool", - "type": "string", - "default": "n2-standard-2" - }, - "metastore_disk_size": { - "description": "Boot disk size (GB) for MetaStore nodes", - "type": "number", - "default": 20 - }, - "ingester_machine_type": { - "description": "GCE machine type for Ingester node pool", - "type": "string", - "default": "n2-standard-4" - }, - "ingester_disk_size": { - "description": "Boot disk size (GB) for Ingester nodes", - "type": "number", - "default": 50 - }, - "signoz_machine_type": { - "description": "GCE machine type for SigNoz node pool", - "type": "string", - "default": "n2-standard-4" - }, - "signoz_disk_size": { - "description": "Boot disk size (GB) for SigNoz nodes", - "type": "number", - "default": 50 - } - } -} diff --git a/internal/infrastructure/terraform/templates/providers.tf.json.gotmpl b/internal/infrastructure/terraform/templates/providers.tf.json.gotmpl deleted file mode 100644 index cdcb200b..00000000 --- a/internal/infrastructure/terraform/templates/providers.tf.json.gotmpl +++ /dev/null @@ -1,32 +0,0 @@ -{ - "terraform": { - "required_version": ">= 1.0.0", - "required_providers": { - {{- if eq .Provider.String "aws" }} - "aws": { - "source": "hashicorp/aws", - "version": "~> 5.0" - } - {{- else if eq .Provider.String "gcp" }} - "google": { - "source": "hashicorp/google", - "version": "~> 5.0" - } - {{- else if eq .Provider.String "azure" }} - "azurerm": { - "source": "hashicorp/azurerm", - "version": "~> 3.0" - } - {{- end }} - } - }, - "provider": { - {{- if eq .Provider.String "aws" }} - "aws": [{}] - {{- else if eq .Provider.String "gcp" }} - "google": [{}] - {{- else if eq .Provider.String "azure" }} - "azurerm": [{"features": [{}]}] - {{- end }} - } -} diff --git a/internal/molding/infrastructure/molding.go b/internal/molding/infrastructure/molding.go new file mode 100644 index 00000000..f0c4d629 --- /dev/null +++ b/internal/molding/infrastructure/molding.go @@ -0,0 +1,17 @@ +package infrastructure + +import ( + "context" + + "github.com/signoz/foundry/api/v1alpha1" + "github.com/signoz/foundry/api/v1alpha1/infrastructure" +) + +type MoldingEnricher interface { + EnrichStatus(ctx context.Context, kind v1alpha1.MoldingKind, config *infrastructure.Casting) error +} + +type Molding interface { + Kind() v1alpha1.MoldingKind + MoldV1Alpha1(ctx context.Context, config *infrastructure.Casting) error +} diff --git a/internal/molding/infrastructure/resourcemolding/resource.go b/internal/molding/infrastructure/resourcemolding/resource.go new file mode 100644 index 00000000..cc909d32 --- /dev/null +++ b/internal/molding/infrastructure/resourcemolding/resource.go @@ -0,0 +1,261 @@ +package resourcemolding + +import ( + "context" + "log/slog" + "maps" + "net/netip" + "slices" + + "github.com/signoz/foundry/api/v1alpha1" + "github.com/signoz/foundry/api/v1alpha1/infrastructure" + "github.com/signoz/foundry/internal/contract" + "github.com/signoz/foundry/internal/domain" + foundryerrors "github.com/signoz/foundry/internal/errors" + infrastructuremolding "github.com/signoz/foundry/internal/molding/infrastructure" +) + +// ResourceConfigName is the document a substrate is described by. +const ResourceConfigName = "resource.yaml" + +// The groups the baseline declares. A casting keys its contribution to these. +const ( + GroupPersistent = "persistent" + GroupEphemeral = "ephemeral" +) + +var _ infrastructuremolding.Molding = (*resourceMolding)(nil) + +type resourceMolding struct { + logger *slog.Logger +} + +func New(logger *slog.Logger) *resourceMolding { + return &resourceMolding{logger: logger} +} + +func (molding *resourceMolding) Kind() v1alpha1.MoldingKind { + return v1alpha1.MoldingKindResource +} + +// MoldV1Alpha1 settles the document from the baseline, the casting's +// contribution and the operator's spec, then validates what settled. Names and +// tags are a casting's, derived from this at forge time. +func (molding *resourceMolding) MoldV1Alpha1(ctx context.Context, config *infrastructure.Casting) error { + status := &config.Spec.Resource.Status + + // Baseline for the resource substrate + baseline := &infrastructure.ResourceConfig{ + Networking: infrastructure.ResourceConfigNetworking{NetworkCIDR: "10.0.0.0/16"}, + InstanceGroups: map[string]infrastructure.ResourceConfigInstanceGroup{ + GroupPersistent: { + Storage: contract.StorageClassPersistent.String(), + MinSize: v1alpha1.IntPtr(3), + MaxSize: v1alpha1.IntPtr(3), + RootVolume: infrastructure.ResourceConfigVolume{Size: v1alpha1.IntPtr(30)}, + DataVolume: &infrastructure.ResourceConfigVolume{Size: v1alpha1.IntPtr(50)}, + }, + GroupEphemeral: { + Storage: contract.StorageClassEphemeral.String(), + MinSize: v1alpha1.IntPtr(1), + MaxSize: v1alpha1.IntPtr(1), + RootVolume: infrastructure.ResourceConfigVolume{Size: v1alpha1.IntPtr(30)}, + }, + }, + } + + baselineDoc, err := domain.MarshalYAML(baseline) + if err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to marshal resource config") + } + + doc := string(baselineDoc) + + // Enricher deltas first, keeping casting-specific keys, then the operator's + // spec, which wins. + for _, override := range []string{ + status.Config.Data[ResourceConfigName], + config.Spec.Resource.Spec.Config.Data[ResourceConfigName], + } { + if override == "" { + continue + } + + doc, err = domain.MergeYAML(doc, override) + if err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInvalidInput, "failed to merge resource config override") + } + } + + declaration := &infrastructure.ResourceConfig{} + if err := domain.UnmarshalYAML([]byte(doc), declaration); err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInvalidInput, "failed to unmarshal resolved resource config") + } + + // Deriving names from it is the casting's, at forge time; the molding only + // checks the name can be a substrate at all. + if _, err := contract.NewSubstrate(config.Metadata.Name); err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInvalidInput, "failed to resolve the substrate being provisioned") + } + + if err := validate(declaration); err != nil { + return err + } + + if status.Config.Data == nil { + status.Config.Data = make(map[string]string) + } + status.Config.Data[ResourceConfigName] = doc + + return nil +} + +// validate checks the shared shape; casting-specific keys pass through. +func validate(declaration *infrastructure.ResourceConfig) error { + if err := validateNetworking(declaration.Networking); err != nil { + return err + } + + return validateInstanceGroups(declaration) +} + +func validateNetworking(networking infrastructure.ResourceConfigNetworking) error { + if networking.NetworkID == "" { + if _, err := netip.ParsePrefix(networking.NetworkCIDR); err != nil { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config networkCIDR %q is not a CIDR block", networking.NetworkCIDR) + } + } + + if len(networking.Subnets) == 0 { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config states no subnets: a substrate cannot place a workload without one, and a zone has no safe default") + } + + // A NAT gateway sits in a public subnet in its own zone. + publicZones := map[string]struct{}{} + private := 0 + + for _, key := range slices.Sorted(maps.Keys(networking.Subnets)) { + subnet := networking.Subnets[key] + + if _, err := contract.NewKey(key); err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInvalidInput, "resource config subnet %q is not a usable reference", key) + } + + subnetType, err := contract.ParseSubnetType(subnet.Type) + if err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInvalidInput, "resource config subnet %q states no usable type", key) + } + + if subnet.Zone == "" { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config subnet %q states no zone", key) + } + + // A network is adopted whole. Half of one leaves foundry routing subnets + // it did not create. + if networking.NetworkID != "" && subnet.ID == "" { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config adopts network %q, so subnet %q states its own id", networking.NetworkID, key) + } + + if networking.NetworkID == "" && subnet.ID != "" { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config subnet %q states an id, but the network it belongs to is created by foundry", key) + } + + if subnet.ID == "" { + if _, err := netip.ParsePrefix(subnet.CIDR); err != nil { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config subnet %q has cidr %q, which is not a CIDR block", key, subnet.CIDR) + } + } else if subnet.Egress != "" { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config subnet %q is adopted, so its egress is not foundry's to state", key) + } + + if subnetType.IsPublic() { + if subnet.Egress != "" { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config subnet %q is public, so it states no egress", key) + } + + if subnet.ID == "" { + publicZones[subnet.Zone] = struct{}{} + } + + continue + } + + private++ + } + + if private == 0 { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config states no private subnet: workloads are never placed in a public one") + } + + for _, key := range slices.Sorted(maps.Keys(networking.Subnets)) { + subnet := networking.Subnets[key] + + // An adopted subnet carries its own routing. + if subnet.Type == contract.SubnetTypePublic.String() || subnet.ID != "" || subnet.Egress != "" { + continue + } + + if _, ok := publicZones[subnet.Zone]; !ok { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config subnet %q needs egress but zone %q has no public subnet to place a gateway in", key, subnet.Zone) + } + } + + return nil +} + +func validateInstanceGroups(declaration *infrastructure.ResourceConfig) error { + if len(declaration.InstanceGroups) == 0 { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config states no instance groups") + } + + for _, key := range slices.Sorted(maps.Keys(declaration.InstanceGroups)) { + group := declaration.InstanceGroups[key] + + if _, err := contract.NewKey(key); err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInvalidInput, "resource config instance group %q is not a usable reference", key) + } + + storage, err := contract.ParseStorageClass(group.Storage) + if err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInvalidInput, "resource config instance group %q states no usable storage class", key) + } + + if group.MachineType == "" { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config instance group %q states no machineType", key) + } + + if group.MinSize == nil || group.MaxSize == nil || group.RootVolume.Size == nil { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config instance group %q is incomplete", key) + } + + if *group.MaxSize < *group.MinSize { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config instance group %q has maxSize below minSize", key) + } + + if storage.IsPinned() && *group.MinSize != *group.MaxSize { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config instance group %q is pinned, so minSize and maxSize must be equal", key) + } + + if storage.RequiresDataVolume() { + if group.DataVolume == nil || group.DataVolume.Size == nil { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config instance group %q must state a dataVolume size", key) + } + } else if group.DataVolume != nil { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config instance group %q cannot state a dataVolume", key) + } + + for _, reference := range group.Subnets { + subnet, ok := declaration.Networking.Subnets[reference] + + if !ok { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config instance group %q is placed in subnet %q, which is not declared", key, reference) + } + + if subnet.Type == contract.SubnetTypePublic.String() { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "resource config instance group %q is placed in subnet %q, which is public", key, reference) + } + } + } + + return nil +} diff --git a/internal/molding/infrastructure/resourcemolding/resource_test.go b/internal/molding/infrastructure/resourcemolding/resource_test.go new file mode 100644 index 00000000..804ddc47 --- /dev/null +++ b/internal/molding/infrastructure/resourcemolding/resource_test.go @@ -0,0 +1,302 @@ +package resourcemolding + +import ( + "context" + "log/slog" + "testing" + + "github.com/signoz/foundry/api/v1alpha1" + "github.com/signoz/foundry/api/v1alpha1/infrastructure" + "github.com/signoz/foundry/internal/contract" + "github.com/signoz/foundry/internal/domain" + "github.com/stretchr/testify/assert" +) + +// What a casting contributes and an operator states between them: the baseline +// carries no zone and no machine type, because neither is the kind's to know. +const ( + networking = `networking: + subnets: + private-a: {type: private, zone: us-east-1a, cidr: 10.0.0.0/19} + public-a: {type: public, zone: us-east-1a, cidr: 10.0.96.0/22} +` + machineTypes = `instanceGroups: + persistent: {machineType: m5.large} + ephemeral: {machineType: c5.large} +` + ephemeralMachineType = `instanceGroups: + ephemeral: {machineType: c5.large} +` +) + +// mold merges the given documents into one declaration, runs the molding over +// a config whose spec carries it, and returns what settled. +func mold(t *testing.T, documents ...string) (string, error) { + t.Helper() + + config := infrastructure.Default() + config.Metadata.Name = "foundry" + + declaration := "" + for _, document := range documents { + if declaration == "" { + declaration = document + continue + } + + merged, err := domain.MergeYAML(declaration, document) + if err != nil { + t.Fatal(err) + } + + declaration = merged + } + + if declaration != "" { + config.Spec.Resource.Spec.Config.Set(ResourceConfigName, []byte(declaration)) + } + + err := New(slog.New(slog.DiscardHandler)).MoldV1Alpha1(context.Background(), config) + + return config.Spec.Resource.Status.Config.Data[ResourceConfigName], err +} + +func TestMoldV1Alpha1(t *testing.T) { + doc, err := mold(t, networking, machineTypes) + assert.NoError(t, err) + + got := infrastructure.ResourceConfig{} + assert.NoError(t, domain.UnmarshalYAML([]byte(doc), &got)) + + // One baseline for every substrate: the default installation shape. + assert.Equal(t, map[string]infrastructure.ResourceConfigInstanceGroup{ + GroupPersistent: { + Storage: contract.StorageClassPersistent.String(), + MachineType: "m5.large", + MinSize: v1alpha1.IntPtr(3), + MaxSize: v1alpha1.IntPtr(3), + RootVolume: infrastructure.ResourceConfigVolume{Size: v1alpha1.IntPtr(30)}, + DataVolume: &infrastructure.ResourceConfigVolume{Size: v1alpha1.IntPtr(50)}, + }, + GroupEphemeral: { + Storage: contract.StorageClassEphemeral.String(), + MachineType: "c5.large", + MinSize: v1alpha1.IntPtr(1), + MaxSize: v1alpha1.IntPtr(1), + RootVolume: infrastructure.ResourceConfigVolume{Size: v1alpha1.IntPtr(30)}, + }, + }, got.InstanceGroups) +} + +// A substrate that keeps nothing drops the persistent group; the null deletes +// the key under RFC 7386. +func TestMoldV1Alpha1_StatelessSubstrate(t *testing.T) { + doc, err := mold(t, networking+"instanceGroups:\n persistent: null\n ephemeral: {machineType: c5.large}\n") + assert.NoError(t, err) + + got := infrastructure.ResourceConfig{} + assert.NoError(t, domain.UnmarshalYAML([]byte(doc), &got)) + assert.Len(t, got.InstanceGroups, 1) + assert.Contains(t, got.InstanceGroups, GroupEphemeral) +} + +// A declaration with no subnets cannot be completed by anything but the +// operator, so it fails rather than guessing a zone. +func TestMoldV1Alpha1_BaselineAloneIsIncomplete(t *testing.T) { + _, err := mold(t) + + assert.Error(t, err) +} + +func TestMoldV1Alpha1_PreservesEnricherContributions(t *testing.T) { + config := infrastructure.Default() + config.Metadata.Name = "foundry" + config.Spec.Resource.Status.Config.Set(ResourceConfigName, []byte(networking+`instanceGroups: + persistent: + machineType: m5.large + minSize: 4 + maxSize: 4 + spotAllocation: lowest-price + ephemeral: + machineType: c5.large + minSize: 2 + maxSize: 2 +`)) + + err := New(slog.New(slog.DiscardHandler)).MoldV1Alpha1(context.Background(), config) + assert.NoError(t, err) + + doc := config.Spec.Resource.Status.Config.Data[ResourceConfigName] + + // Casting-specific keys survive the merge untouched. + assert.Contains(t, doc, "spotAllocation") + + got := infrastructure.ResourceConfig{} + assert.NoError(t, domain.UnmarshalYAML([]byte(doc), &got)) + + // Groups are keyed, so the contribution states only the fields it changes + // and the baseline's others survive under plain document merge, with no + // list strategy. + assert.Len(t, got.InstanceGroups, 2) + + persistent := got.InstanceGroups[GroupPersistent] + assert.Equal(t, v1alpha1.IntPtr(4), persistent.MinSize) + assert.Equal(t, v1alpha1.IntPtr(50), persistent.DataVolume.Size) + + ephemeral := got.InstanceGroups[GroupEphemeral] + assert.Equal(t, v1alpha1.IntPtr(2), ephemeral.MinSize) + assert.Equal(t, v1alpha1.IntPtr(30), ephemeral.RootVolume.Size) +} + +// The operator's spec beats the casting's contribution wherever the two +// disagree. +func TestMoldV1Alpha1_SpecBeatsContribution(t *testing.T) { + config := infrastructure.Default() + config.Metadata.Name = "foundry" + config.Spec.Resource.Status.Config.Set(ResourceConfigName, []byte(machineTypes)) + config.Spec.Resource.Spec.Config.Set(ResourceConfigName, []byte(networking+`instanceGroups: + persistent: {machineType: r5.xlarge} +`)) + + err := New(slog.New(slog.DiscardHandler)).MoldV1Alpha1(context.Background(), config) + assert.NoError(t, err) + + got := infrastructure.ResourceConfig{} + assert.NoError(t, domain.UnmarshalYAML([]byte(config.Spec.Resource.Status.Config.Data[ResourceConfigName]), &got)) + assert.Equal(t, "r5.xlarge", got.InstanceGroups[GroupPersistent].MachineType) + assert.Equal(t, "c5.large", got.InstanceGroups[GroupEphemeral].MachineType) +} + +func TestValidate(t *testing.T) { + tests := []struct { + name string + documents []string + pass bool + }{ + { + // A partial override is the point of keying: the baseline supplies + // everything the declaration does not mention. + name: "PartialOverride_Valid", + documents: []string{networking, machineTypes, "instanceGroups:\n persistent: {minSize: 1, maxSize: 1}\n"}, + pass: true, + }, + { + name: "NoSubnets_Invalid", + documents: []string{machineTypes}, + pass: false, + }, + { + name: "SubnetWithoutZone_Invalid", + documents: []string{"networking:\n subnets:\n private-a: {type: private, cidr: 10.0.0.0/19}\n", machineTypes}, + pass: false, + }, + { + name: "SubnetWithoutType_Invalid", + documents: []string{"networking:\n subnets:\n private-a: {zone: us-east-1a, cidr: 10.0.0.0/19}\n", machineTypes}, + pass: false, + }, + { + name: "SubnetWithBadCIDR_Invalid", + documents: []string{"networking:\n subnets:\n private-a: {type: private, zone: us-east-1a, cidr: 10.0.0.0}\n", machineTypes}, + pass: false, + }, + { + name: "SubnetKeyNotAName_Invalid", + documents: []string{"networking:\n subnets:\n Private_A: {type: private, zone: us-east-1a, cidr: 10.0.0.0/19}\n", machineTypes}, + pass: false, + }, + { + name: "OnlyPublicSubnets_Invalid", + documents: []string{"networking:\n subnets:\n public-a: {type: public, zone: us-east-1a, cidr: 10.0.96.0/22}\n", machineTypes}, + pass: false, + }, + { + // Nothing can place the gateway the private subnet needs. + name: "PrivateSubnetWithNoPublicInItsZone_Invalid", + documents: []string{"networking:\n subnets:\n private-b: {type: private, zone: us-east-1b, cidr: 10.0.32.0/19}\n public-a: {type: public, zone: us-east-1a, cidr: 10.0.96.0/22}\n", machineTypes}, + pass: false, + }, + { + // An adopted egress path is one the operator already routes through. + name: "PrivateSubnetWithAdoptedEgress_Valid", + documents: []string{"networking:\n subnets:\n private-b: {type: private, zone: us-east-1b, cidr: 10.0.32.0/19, egress: nat-0a1b2c3d}\n", machineTypes}, + pass: true, + }, + { + name: "PublicSubnetWithEgress_Invalid", + documents: []string{"networking:\n subnets:\n private-a: {type: private, zone: us-east-1a, cidr: 10.0.0.0/19}\n public-a: {type: public, zone: us-east-1a, cidr: 10.0.96.0/22, egress: nat-0a1b2c3d}\n", machineTypes}, + pass: false, + }, + { + name: "AdoptedNetworkWithAdoptedSubnets_Valid", + documents: []string{"networking:\n networkID: vpc-0a1b2c3d\n subnets:\n private-a: {type: private, zone: us-east-1a, id: subnet-0a1b2c3d}\n", machineTypes}, + pass: true, + }, + { + name: "AdoptedNetworkWithCreatedSubnet_Invalid", + documents: []string{"networking:\n networkID: vpc-0a1b2c3d\n subnets:\n private-a: {type: private, zone: us-east-1a, cidr: 10.0.0.0/19}\n", machineTypes}, + pass: false, + }, + { + name: "AdoptedSubnetInCreatedNetwork_Invalid", + documents: []string{"networking:\n subnets:\n private-a: {type: private, zone: us-east-1a, id: subnet-0a1b2c3d}\n", machineTypes}, + pass: false, + }, + { + name: "GroupWithoutMachineType_Invalid", + documents: []string{networking}, + pass: false, + }, + { + name: "PinnedGroupWithUnequalBounds_Invalid", + documents: []string{networking, machineTypes, "instanceGroups:\n persistent: {minSize: 3, maxSize: 5}\n"}, + pass: false, + }, + { + name: "GroupWithMaxBelowMin_Invalid", + documents: []string{networking, machineTypes, "instanceGroups:\n ephemeral: {minSize: 4, maxSize: 2}\n"}, + pass: false, + }, + { + // A null deletes the key under RFC 7386, so this removes the + // baseline's data volume from a class that requires one. It is one + // document because a null merged onto a document that never had + // the key is dropped, not carried forward. + name: "PersistentWithoutDataVolume_Invalid", + documents: []string{networking + "instanceGroups:\n persistent: {machineType: m5.large, dataVolume: null}\n ephemeral: {machineType: c5.large}\n"}, + pass: false, + }, + { + name: "EphemeralWithDataVolume_Invalid", + documents: []string{networking, machineTypes, "instanceGroups:\n ephemeral: {dataVolume: {size: 20}}\n"}, + pass: false, + }, + { + name: "GroupKeyNotAName_Invalid", + documents: []string{networking, machineTypes, "instanceGroups:\n Hot_Pool: {storage: ephemeral, machineType: c5.large, minSize: 1, maxSize: 1, rootVolume: {size: 30}}\n"}, + pass: false, + }, + { + name: "GroupPlacedInUndeclaredSubnet_Invalid", + documents: []string{networking, machineTypes, "instanceGroups:\n persistent: {subnets: [private-z]}\n"}, + pass: false, + }, + { + name: "GroupPlacedInPublicSubnet_Invalid", + documents: []string{networking, machineTypes, "instanceGroups:\n persistent: {subnets: [public-a]}\n"}, + pass: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := mold(t, tt.documents...) + if !tt.pass { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + }) + } +}