diff --git a/cmd/foundryctl/cast.go b/cmd/foundryctl/cast.go index 82126fce..8aab4a3b 100644 --- a/cmd/foundryctl/cast.go +++ b/cmd/foundryctl/cast.go @@ -9,6 +9,7 @@ import ( "github.com/signoz/foundry/internal/domain" "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/foundry" + "github.com/signoz/foundry/internal/tooler" "github.com/spf13/cobra" ) @@ -19,6 +20,10 @@ func registerCastCmd(rootCmd *cobra.Command) { RunE: recoverRunE(domain.EventCast, func(cmd *cobra.Command, args []string, report reporter) error { ctx := cmd.Context() + if castCfg.Yes { + ctx = tooler.WithApproval(ctx) + } + // A document the inner stages pass is prepared, not cast, so // only their failures report. prepReport := reporter(func(props domain.Properties, err error) { diff --git a/cmd/foundryctl/config.go b/cmd/foundryctl/config.go index a02572aa..2f7af71a 100644 --- a/cmd/foundryctl/config.go +++ b/cmd/foundryctl/config.go @@ -12,6 +12,9 @@ var ( // Stores cast configuration. castCfg castConfig + // Stores melt configuration. + meltCfg meltConfig + // Stores catalog configuration. catalogCfg catalogConfig ) @@ -43,11 +46,21 @@ func (c *poursConfig) RegisterFlags(cmd *cobra.Command) { type castConfig struct { NoGauge bool NoForge bool + Yes bool +} + +type meltConfig struct { + Yes bool +} + +func (c *meltConfig) RegisterFlags(cmd *cobra.Command) { + cmd.PersistentFlags().BoolVar(&c.Yes, "yes", false, "Confirm the verbs that change infrastructure.") } func (c *castConfig) RegisterFlags(cmd *cobra.Command) { cmd.PersistentFlags().BoolVar(&c.NoGauge, "no-gauge", false, "Do not run gauge before forge and cast.") cmd.PersistentFlags().BoolVar(&c.NoForge, "no-forge", false, "Do not run forge before cast.") + cmd.PersistentFlags().BoolVar(&c.Yes, "yes", false, "Confirm the verbs that change infrastructure.") } type catalogConfig struct { diff --git a/cmd/foundryctl/main.go b/cmd/foundryctl/main.go index bcc3b560..d9270102 100644 --- a/cmd/foundryctl/main.go +++ b/cmd/foundryctl/main.go @@ -1,7 +1,10 @@ package main import ( + "context" "os" + "os/signal" + "syscall" foundryerrors "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/version" @@ -9,6 +12,7 @@ import ( ) func main() { + rootCmd := &cobra.Command{ Use: "foundryctl", SilenceUsage: true, @@ -26,11 +30,24 @@ func main() { registerGaugeCmd(rootCmd) registerForgeCmd(rootCmd) registerCastCmd(rootCmd) + registerMeltCmd(rootCmd) registerGenCmd(rootCmd) registerCatalogCmd(rootCmd) registerVersionCmd(rootCmd) - err := rootCmd.Execute() + // Foundry survives the interrupt to keep reading the tool's streams: dying + // here SIGPIPEs the tool mid-write. Exec'd tools get the kernel's copy + // directly; the cancelled context is the relay only for in-process SDK work. + // A second signal kills foundry by the OS default. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + go func() { + <-ctx.Done() + stop() + }() + + err := rootCmd.ExecuteContext(ctx) if rootNotifier != nil { rootNotifier.Finish(version.Info.Version(), os.Stderr) diff --git a/cmd/foundryctl/melt.go b/cmd/foundryctl/melt.go new file mode 100644 index 00000000..06d6883c --- /dev/null +++ b/cmd/foundryctl/melt.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "log/slog" + "path/filepath" + "slices" + + "github.com/signoz/foundry/internal/domain" + foundryerrors "github.com/signoz/foundry/internal/errors" + "github.com/signoz/foundry/internal/foundry" + "github.com/signoz/foundry/internal/tooler" + "github.com/spf13/cobra" +) + +func registerMeltCmd(rootCmd *cobra.Command) { + meltCmd := &cobra.Command{ + Use: "melt", + Short: "Remove the cast deployment", + Long: "Remove the cast deployment from the target environment; data is never touched", + RunE: recoverRunE(domain.EventMelt, func(cmd *cobra.Command, args []string, report reporter) error { + ctx := cmd.Context() + + if meltCfg.Yes { + ctx = tooler.WithApproval(ctx) + } + + return runMelt(ctx, rootLogger, poursCfg.Path, commonCfg.File, report) + }), + } + + rootCmd.AddCommand(meltCmd) + meltCfg.RegisterFlags(meltCmd) +} + +func runMelt(ctx context.Context, logger *slog.Logger, poursPath string, configPath string, report reporter) error { + foundry, err := foundry.New(logger) + if err != nil { + return err + } + + poursPath, err = filepath.Abs(poursPath) + if err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to resolve pours path") + } + + machineries, err := foundry.Config.GetV1Alpha1Lock(ctx, configPath) + if err != nil { + return err + } + + // Backwards against the order the lock records, so a workload leaves + // before the substrate it runs on. + for _, machinery := range slices.Backward(machineries) { + if err := foundry.Melt(ctx, machinery, poursPath); err != nil { + report(machinery.TrackableProperties(), err) + + return err + } + + report(machinery.TrackableProperties(), nil) + } + + return nil +} diff --git a/docs/examples/docker/compose-mcp/pours/deployment/compose.yaml b/docs/examples/docker/compose-mcp/pours/deployment/compose.yaml index 0c7ce940..a59c73f4 100644 --- a/docs/examples/docker/compose-mcp/pours/deployment/compose.yaml +++ b/docs/examples/docker/compose-mcp/pours/deployment/compose.yaml @@ -17,6 +17,10 @@ services: - SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://signoz-telemetrystore-clickhouse-0-0:9000 - SIGNOZ_OTEL_COLLECTOR_TIMEOUT=10m image: signoz/signoz-otel-collector:latest + labels: + foundry.signoz.io/kind: Installation + foundry.signoz.io/managed-by: foundry + foundry.signoz.io/name: signoz networks: signoz-network: aliases: @@ -36,6 +40,10 @@ services: - SIGNOZ_URL=http://signoz-signoz-0:8080 - TRANSPORT_MODE=http image: signoz/signoz-mcp-server:latest + labels: + foundry.signoz.io/kind: Installation + foundry.signoz.io/managed-by: foundry + foundry.signoz.io/name: signoz networks: signoz-network: aliases: @@ -58,6 +66,10 @@ services: - pg_isready -U signoz -d signoz timeout: 10s image: postgres:16 + labels: + foundry.signoz.io/kind: Installation + foundry.signoz.io/managed-by: foundry + foundry.signoz.io/name: signoz networks: - signoz-network restart: unless-stopped @@ -82,6 +94,10 @@ services: - http://localhost:8080/api/v1/health timeout: 10s image: signoz/signoz:latest + labels: + foundry.signoz.io/kind: Installation + foundry.signoz.io/managed-by: foundry + foundry.signoz.io/name: signoz networks: - signoz-network ports: @@ -107,6 +123,10 @@ services: - ls timeout: 10s image: clickhouse/clickhouse-keeper:25.12.5 + labels: + foundry.signoz.io/kind: Installation + foundry.signoz.io/managed-by: foundry + foundry.signoz.io/name: signoz networks: - signoz-network restart: unless-stopped @@ -135,6 +155,10 @@ services: - http://localhost:8123/ping timeout: 10s image: clickhouse/clickhouse-server:25.12.5 + labels: + foundry.signoz.io/kind: Installation + foundry.signoz.io/managed-by: foundry + foundry.signoz.io/name: signoz networks: - signoz-network restart: unless-stopped @@ -158,6 +182,10 @@ services: mv histogram-quantile /var/lib/clickhouse/user_scripts/histogramQuantile container_name: signoz-telemetrystore-clickhouse-user-scripts image: clickhouse/clickhouse-server:25.12.5 + labels: + foundry.signoz.io/kind: Installation + foundry.signoz.io/managed-by: foundry + foundry.signoz.io/name: signoz networks: - signoz-network restart: on-failure @@ -178,6 +206,10 @@ services: - SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://signoz-telemetrystore-clickhouse-0-0:9000 - SIGNOZ_OTEL_COLLECTOR_TIMEOUT=10m image: signoz/signoz-otel-collector:latest + labels: + foundry.signoz.io/kind: Installation + foundry.signoz.io/managed-by: foundry + foundry.signoz.io/name: signoz networks: - signoz-network restart: on-failure diff --git a/docs/examples/docker/compose/pours/deployment/compose.yaml b/docs/examples/docker/compose/pours/deployment/compose.yaml index c3f1acb1..8f6150d0 100644 --- a/docs/examples/docker/compose/pours/deployment/compose.yaml +++ b/docs/examples/docker/compose/pours/deployment/compose.yaml @@ -17,6 +17,10 @@ services: - SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://signoz-telemetrystore-clickhouse-0-0:9000 - SIGNOZ_OTEL_COLLECTOR_TIMEOUT=10m image: signoz/signoz-otel-collector:latest + labels: + foundry.signoz.io/kind: Installation + foundry.signoz.io/managed-by: foundry + foundry.signoz.io/name: signoz networks: signoz-network: aliases: @@ -43,6 +47,10 @@ services: - pg_isready -U signoz -d signoz timeout: 10s image: postgres:16 + labels: + foundry.signoz.io/kind: Installation + foundry.signoz.io/managed-by: foundry + foundry.signoz.io/name: signoz networks: - signoz-network restart: unless-stopped @@ -67,6 +75,10 @@ services: - http://localhost:8080/api/v1/health timeout: 10s image: signoz/signoz:latest + labels: + foundry.signoz.io/kind: Installation + foundry.signoz.io/managed-by: foundry + foundry.signoz.io/name: signoz networks: - signoz-network ports: @@ -92,6 +104,10 @@ services: - ls timeout: 10s image: clickhouse/clickhouse-keeper:25.12.5 + labels: + foundry.signoz.io/kind: Installation + foundry.signoz.io/managed-by: foundry + foundry.signoz.io/name: signoz networks: - signoz-network restart: unless-stopped @@ -120,6 +136,10 @@ services: - http://localhost:8123/ping timeout: 10s image: clickhouse/clickhouse-server:25.12.5 + labels: + foundry.signoz.io/kind: Installation + foundry.signoz.io/managed-by: foundry + foundry.signoz.io/name: signoz networks: - signoz-network restart: unless-stopped @@ -143,6 +163,10 @@ services: mv histogram-quantile /var/lib/clickhouse/user_scripts/histogramQuantile container_name: signoz-telemetrystore-clickhouse-user-scripts image: clickhouse/clickhouse-server:25.12.5 + labels: + foundry.signoz.io/kind: Installation + foundry.signoz.io/managed-by: foundry + foundry.signoz.io/name: signoz networks: - signoz-network restart: on-failure @@ -163,6 +187,10 @@ services: - SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://signoz-telemetrystore-clickhouse-0-0:9000 - SIGNOZ_OTEL_COLLECTOR_TIMEOUT=10m image: signoz/signoz-otel-collector:latest + labels: + foundry.signoz.io/kind: Installation + foundry.signoz.io/managed-by: foundry + foundry.signoz.io/name: signoz networks: - signoz-network restart: on-failure diff --git a/docs/examples/kubernetes/kustomize/pours/deployment/kustomization.yaml b/docs/examples/kubernetes/kustomize/pours/deployment/kustomization.yaml index 9a2c406b..2d51511d 100644 --- a/docs/examples/kubernetes/kustomize/pours/deployment/kustomization.yaml +++ b/docs/examples/kubernetes/kustomize/pours/deployment/kustomization.yaml @@ -1,5 +1,11 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization +labels: +- includeSelectors: false + pairs: + foundry.signoz.io/kind: Installation + foundry.signoz.io/managed-by: foundry + foundry.signoz.io/name: signoz namespace: signoz resources: - namespace.yaml diff --git a/go.mod b/go.mod index fd8d9294..36371f5f 100644 --- a/go.mod +++ b/go.mod @@ -16,9 +16,13 @@ require ( github.com/tidwall/gjson v1.18.0 go.yaml.in/yaml/v3 v3.0.4 golang.org/x/mod v0.35.0 + golang.org/x/oauth2 v0.36.0 gopkg.in/ini.v1 v1.67.1 helm.sh/helm/v3 v3.20.2 k8s.io/apimachinery v0.35.1 + k8s.io/cli-runtime v0.35.1 + k8s.io/client-go v0.35.1 + sigs.k8s.io/kustomize/api v0.20.1 sigs.k8s.io/kustomize/kyaml v0.21.1 sigs.k8s.io/yaml v1.6.0 ) @@ -109,7 +113,6 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/crypto v0.52.0 // indirect golang.org/x/net v0.55.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/term v0.43.0 // indirect @@ -124,8 +127,6 @@ require ( k8s.io/api v0.35.1 // indirect k8s.io/apiextensions-apiserver v0.35.1 // indirect k8s.io/apiserver v0.35.1 // indirect - k8s.io/cli-runtime v0.35.1 // indirect - k8s.io/client-go v0.35.1 // indirect k8s.io/component-base v0.35.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect @@ -133,7 +134,6 @@ require ( k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect oras.land/oras-go/v2 v2.6.2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect - sigs.k8s.io/kustomize/api v0.20.1 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect ) diff --git a/internal/casting/casting.go b/internal/casting/casting.go index 5d0ba829..8b3c74cd 100644 --- a/internal/casting/casting.go +++ b/internal/casting/casting.go @@ -6,6 +6,7 @@ import ( "github.com/signoz/foundry/api/v1alpha1/installation" "github.com/signoz/foundry/internal/domain" "github.com/signoz/foundry/internal/molding" + "github.com/signoz/foundry/internal/tooler" ) // DeploymentDir is the subdirectory within the pours directory where @@ -19,6 +20,11 @@ type Casting interface { // Generates all the files needed for casting. Forge(ctx context.Context, config installation.Casting, poursPath string) ([]domain.Material, error) - // Runs the forged files. - Cast(ctx context.Context, config installation.Casting, poursPath string) error + // Runs the forged files. Toolers are the tool interfaces the registry + // lists for this casting. + Cast(ctx context.Context, config installation.Casting, poursPath string, toolers []tooler.Tooler) error + + // Removes what Cast deployed: definitions only, never data, never + // users, config stays. + Melt(ctx context.Context, config installation.Casting, poursPath string, toolers []tooler.Tooler) error } diff --git a/internal/casting/collectionagent/casting.go b/internal/casting/collectionagent/casting.go index 30c94126..2599b81f 100644 --- a/internal/casting/collectionagent/casting.go +++ b/internal/casting/collectionagent/casting.go @@ -6,10 +6,12 @@ import ( "github.com/signoz/foundry/api/v1alpha1/collectionagent" collectionagentmolding "github.com/signoz/foundry/internal/molding/collectionagent" "github.com/signoz/foundry/internal/pourer" + "github.com/signoz/foundry/internal/tooler" ) type Casting interface { Enricher(ctx context.Context, config *collectionagent.Casting) (collectionagentmolding.MoldingEnricher, error) Forge(ctx context.Context, config collectionagent.Casting, p *pourer.Pourer) error - Cast(ctx context.Context, config collectionagent.Casting, outputPath string, p *pourer.Pourer) error + Cast(ctx context.Context, config collectionagent.Casting, outputPath string, p *pourer.Pourer, toolers []tooler.Tooler) error + Melt(ctx context.Context, config collectionagent.Casting, outputPath string, p *pourer.Pourer, toolers []tooler.Tooler) error } diff --git a/internal/casting/collectionagent/dockercomposecasting/casting.go b/internal/casting/collectionagent/dockercomposecasting/casting.go index c93e4a05..6d11706d 100644 --- a/internal/casting/collectionagent/dockercomposecasting/casting.go +++ b/internal/casting/collectionagent/dockercomposecasting/casting.go @@ -4,17 +4,16 @@ import ( "bytes" "context" "log/slog" - "os" - "os/exec" "path/filepath" "strings" - "github.com/signoz/foundry/api/v1alpha1" "github.com/signoz/foundry/api/v1alpha1/collectionagent" "github.com/signoz/foundry/internal/domain" foundryerrors "github.com/signoz/foundry/internal/errors" collectionagentmolding "github.com/signoz/foundry/internal/molding/collectionagent" "github.com/signoz/foundry/internal/pourer" + "github.com/signoz/foundry/internal/tooler" + "github.com/signoz/foundry/internal/tooler/dockercomposetooler" ) type dockerComposeCasting struct { @@ -44,70 +43,30 @@ func (c *dockerComposeCasting) Forge(ctx context.Context, config collectionagent return nil } -func (c *dockerComposeCasting) Cast(ctx context.Context, config collectionagent.Casting, outputPath string, p *pourer.Pourer) error { - composeFile := filepath.Join(outputPath, p.Dir(), "compose.yaml") - - if _, err := os.Stat(composeFile); os.IsNotExist(err) { - return foundryerrors.Newf(foundryerrors.TypeNotFound, "compose file does not exist at path: %s", composeFile) - } - - if err := c.checkOwnership(ctx, config); err != nil { +func (c *dockerComposeCasting) Cast(ctx context.Context, config collectionagent.Casting, outputPath string, p *pourer.Pourer, toolers []tooler.Tooler) error { + compose, err := dockercomposetooler.Lookup(toolers) + if err != nil { return err } - composeCmd, err := getComposeCommand(ctx) - if err != nil { - return foundryerrors.Wrapf(err, foundryerrors.TypeNotFound, "docker compose not available") + release := dockercomposetooler.Release{ + Release: domain.Release{Name: config.Metadata.Name, Owner: config.Labels()}, + File: filepath.Join(outputPath, p.Dir(), strings.TrimSuffix(composeYAMLTemplate.Name(), ".gotmpl")), } - args := append(composeCmd[1:], "-f", composeFile, "up", "-d") - - c.logger.DebugContext(ctx, "running command", slog.String("command", strings.Join(append([]string{composeCmd[0]}, args...), " "))) - - cmd := exec.CommandContext(ctx, composeCmd[0], args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - return cmd.Run() + return compose.Up(ctx, release) } -// checkOwnership refuses to deploy over a compose project of the same name -// that belongs to a different foundry Kind. Unlabeled containers only warn: -// they are either a pre-label foundry deployment or a foreign project. -func (c *dockerComposeCasting) checkOwnership(ctx context.Context, config collectionagent.Casting) error { - out, err := exec.CommandContext(ctx, "docker", "ps", "-a", - "--filter", "label=com.docker.compose.project="+config.Metadata.Name, - "--format", `{{.Label "`+v1alpha1.LabelKind.Key+`"}}`).Output() +// Melt removes the agent's containers and networks; volumes stay. +func (c *dockerComposeCasting) Melt(ctx context.Context, config collectionagent.Casting, outputPath string, p *pourer.Pourer, toolers []tooler.Tooler) error { + compose, err := dockercomposetooler.Lookup(toolers) if err != nil { - c.logger.WarnContext(ctx, "skipping the ownership check: could not read labels from docker", foundryerrors.LogAttr(err)) - return nil - } - - ownership := domain.ParseOwnership(string(out)) - - if foreign, conflict := ownership.Foreign(config.Kind().String()); conflict { - return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "%q already belongs to a foundry %s on this host: choose a different metadata.name or remove the existing deployment", config.Metadata.Name, foreign) - } - - if ownership.HasUnlabeled() { - c.logger.WarnContext(ctx, "compose project has containers without foundry ownership labels", slog.String("project", config.Metadata.Name)) - } - - return nil -} - -func getComposeCommand(ctx context.Context) ([]string, error) { - if _, err := exec.LookPath("docker"); err == nil { - cmd := exec.CommandContext(ctx, "docker", "compose", "version") - - if err := cmd.Run(); err == nil { - return []string{"docker", "compose"}, nil - } + return err } - if _, err := exec.LookPath("docker-compose"); err == nil { - return []string{"docker-compose"}, nil + release := dockercomposetooler.Release{ + Release: domain.Release{Name: config.Metadata.Name, Owner: config.Labels()}, + File: filepath.Join(outputPath, p.Dir(), strings.TrimSuffix(composeYAMLTemplate.Name(), ".gotmpl")), } - - return nil, foundryerrors.Newf(foundryerrors.TypeNotFound, "neither 'docker compose' nor 'docker-compose' is available") + return compose.Down(ctx, release) } diff --git a/internal/casting/collectionagent/dockerswarmcasting/casting.go b/internal/casting/collectionagent/dockerswarmcasting/casting.go index 9d08d466..fcbeb8c9 100644 --- a/internal/casting/collectionagent/dockerswarmcasting/casting.go +++ b/internal/casting/collectionagent/dockerswarmcasting/casting.go @@ -4,18 +4,16 @@ import ( "bytes" "context" "log/slog" - "os" - "os/exec" "path/filepath" "strings" - "time" - "github.com/signoz/foundry/api/v1alpha1" "github.com/signoz/foundry/api/v1alpha1/collectionagent" "github.com/signoz/foundry/internal/domain" foundryerrors "github.com/signoz/foundry/internal/errors" collectionagentmolding "github.com/signoz/foundry/internal/molding/collectionagent" "github.com/signoz/foundry/internal/pourer" + "github.com/signoz/foundry/internal/tooler" + "github.com/signoz/foundry/internal/tooler/dockerswarmtooler" ) type dockerSwarmCasting struct { @@ -45,52 +43,32 @@ func (c *dockerSwarmCasting) Forge(ctx context.Context, config collectionagent.C return nil } -func (c *dockerSwarmCasting) Cast(ctx context.Context, config collectionagent.Casting, outputPath string, p *pourer.Pourer) error { - composeFile := filepath.Join(outputPath, p.Dir(), "compose.yaml") - - if _, err := os.Stat(composeFile); os.IsNotExist(err) { - return foundryerrors.Newf(foundryerrors.TypeNotFound, "compose file does not exist at path: %s", composeFile) - } - - if err := c.checkOwnership(ctx, config); err != nil { +func (c *dockerSwarmCasting) Cast(ctx context.Context, config collectionagent.Casting, outputPath string, p *pourer.Pourer, toolers []tooler.Tooler) error { + swarm, err := dockerswarmtooler.Lookup(toolers) + if err != nil { return err } - runctx, cancel := context.WithTimeout(ctx, 5*time.Minute) - defer cancel() - - args := []string{"stack", "deploy", "-d", "-c", composeFile, config.Metadata.Name} - - c.logger.DebugContext(runctx, "running command", slog.String("command", strings.Join(append([]string{"docker"}, args...), " "))) - - cmd := exec.CommandContext(runctx, "docker", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + release := dockerswarmtooler.Release{ + Release: domain.Release{Name: config.Metadata.Name, Owner: config.Labels()}, + File: filepath.Join(outputPath, p.Dir(), strings.TrimSuffix(composeYAMLTemplate.Name(), ".gotmpl")), + } - return cmd.Run() + return swarm.Up(ctx, release) } -// checkOwnership refuses to deploy over a swarm stack of the same name that -// belongs to a different foundry Kind. Unlabeled task containers only warn: -// they are either a pre-label foundry deployment or a foreign stack. -func (c *dockerSwarmCasting) checkOwnership(ctx context.Context, config collectionagent.Casting) error { - out, err := exec.CommandContext(ctx, "docker", "ps", "-a", - "--filter", "label=com.docker.stack.namespace="+config.Metadata.Name, - "--format", `{{.Label "`+v1alpha1.LabelKind.Key+`"}}`).Output() +// Melt removes the stack's services and networks; the volumes holding +// component data stay. +func (c *dockerSwarmCasting) Melt(ctx context.Context, config collectionagent.Casting, outputPath string, p *pourer.Pourer, toolers []tooler.Tooler) error { + swarm, err := dockerswarmtooler.Lookup(toolers) if err != nil { - c.logger.WarnContext(ctx, "skipping the ownership check: could not read labels from docker", foundryerrors.LogAttr(err)) - return nil - } - - ownership := domain.ParseOwnership(string(out)) - - if foreign, conflict := ownership.Foreign(config.Kind().String()); conflict { - return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "%q already belongs to a foundry %s on this host: choose a different metadata.name or remove the existing deployment", config.Metadata.Name, foreign) + return err } - if ownership.HasUnlabeled() { - c.logger.WarnContext(ctx, "swarm stack has task containers without foundry ownership labels", slog.String("stack", config.Metadata.Name)) + release := dockerswarmtooler.Release{ + Release: domain.Release{Name: config.Metadata.Name, Owner: config.Labels()}, + File: filepath.Join(outputPath, p.Dir(), strings.TrimSuffix(composeYAMLTemplate.Name(), ".gotmpl")), } - return nil + return swarm.Down(ctx, release) } diff --git a/internal/casting/collectionagent/planner.go b/internal/casting/collectionagent/planner.go index d4d7c34a..cf3b94c8 100644 --- a/internal/casting/collectionagent/planner.go +++ b/internal/casting/collectionagent/planner.go @@ -99,7 +99,11 @@ func (p *Planner) Forge(ctx context.Context, target string) ([]domain.Material, } 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()))) + return p.casting.Cast(ctx, *p.config, poursPath, pourer.New(strings.ToLower(p.config.Kind().String())), p.toolers) +} + +func (p *Planner) Melt(ctx context.Context, poursPath string) error { + return p.casting.Melt(ctx, *p.config, poursPath, pourer.New(strings.ToLower(p.config.Kind().String())), p.toolers) } func (p *Planner) Toolers() []tooler.Tooler { return p.toolers } diff --git a/internal/casting/collectionagent/registry.go b/internal/casting/collectionagent/registry.go index 3a293f00..f4b5399a 100644 --- a/internal/casting/collectionagent/registry.go +++ b/internal/casting/collectionagent/registry.go @@ -10,11 +10,11 @@ import ( "github.com/signoz/foundry/internal/tooler" "github.com/signoz/foundry/internal/tooler/dockercomposetooler" "github.com/signoz/foundry/internal/tooler/dockerswarmtooler" - "github.com/signoz/foundry/internal/tooler/dockertooler" ) type CastingItem struct { Casting Casting + Toolers []tooler.Tooler } @@ -30,14 +30,14 @@ func NewRegistry(logger *slog.Logger) *Registry { Flavor: v1alpha1.FlavorCompose, }: { Casting: dockercomposecasting.New(logger), - Toolers: []tooler.Tooler{dockertooler.New(), dockercomposetooler.New()}, + Toolers: []tooler.Tooler{dockercomposetooler.New(logger)}, }, { Mode: v1alpha1.ModeDocker, Flavor: v1alpha1.FlavorSwarm, }: { Casting: dockerswarmcasting.New(logger), - Toolers: []tooler.Tooler{dockertooler.New(), dockerswarmtooler.New()}, + Toolers: []tooler.Tooler{dockerswarmtooler.New(logger)}, }, }, } diff --git a/internal/casting/coolifycasting/casting.go b/internal/casting/coolifycasting/casting.go index e7c58187..8dd9139f 100644 --- a/internal/casting/coolifycasting/casting.go +++ b/internal/casting/coolifycasting/casting.go @@ -11,6 +11,7 @@ import ( "github.com/signoz/foundry/internal/domain" "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/molding" + "github.com/signoz/foundry/internal/tooler" ) var _ rootcasting.Casting = (*coolifyCasting)(nil) @@ -48,7 +49,7 @@ func (c *coolifyCasting) Forge(ctx context.Context, config installation.Casting, return []domain.Material{coolifyMaterial}, nil } -func (c *coolifyCasting) Cast(ctx context.Context, config installation.Casting, poursPath string) error { +func (c *coolifyCasting) Cast(ctx context.Context, config installation.Casting, poursPath string, _ []tooler.Tooler) error { c.logger.InfoContext(ctx, "Please run 'forge' first to generate the Coolify Casting", slog.String("pours_path", poursPath)) c.logger.InfoContext(ctx, "After forging, deploy coolify.yaml to Coolify using the stack feature", @@ -56,6 +57,14 @@ func (c *coolifyCasting) Cast(ctx context.Context, config installation.Casting, return nil } +// Melt tells the operator where to remove the deployment: foundry does not +// drive Coolify. +func (c *coolifyCasting) Melt(ctx context.Context, config installation.Casting, poursPath string, _ []tooler.Tooler) error { + c.logger.InfoContext(ctx, "Remove the stack from Coolify directly; foundry does not manage Coolify resources", + slog.String("docs", "https://coolify.io/docs/knowledge-base/docker/compose")) + return nil +} + func getCoolifyMaterial(config *installation.Casting, path string) (domain.StructuredMaterial, error) { buf := bytes.NewBuffer(nil) err := coolifyYAMLTemplate.Execute(buf, config) diff --git a/internal/casting/coolifycasting/enricher.go b/internal/casting/coolifycasting/enricher.go index 802faeb3..4e496a69 100644 --- a/internal/casting/coolifycasting/enricher.go +++ b/internal/casting/coolifycasting/enricher.go @@ -112,7 +112,6 @@ func (enricher *coolifyMoldingEnricher) EnrichStatus(ctx context.Context, kind v config.Spec.Ingester.Status.Addresses.OTLP = []string{ domain.MustNewAddress("tcp", config.Metadata.Name+"-ingester", 4318).String(), domain.MustNewAddress("tcp", config.Metadata.Name+"-ingester", 4317).String(), - } case v1alpha1.MoldingKindMCP: if !config.Spec.MCP.Spec.IsEnabled() { diff --git a/internal/casting/dockercomposecasting/casting.go b/internal/casting/dockercomposecasting/casting.go index a3af3c88..83db69d0 100644 --- a/internal/casting/dockercomposecasting/casting.go +++ b/internal/casting/dockercomposecasting/casting.go @@ -3,10 +3,7 @@ package dockercomposecasting import ( "bytes" "context" - "errors" "log/slog" - "os" - "os/exec" "path/filepath" "strings" @@ -15,6 +12,8 @@ import ( "github.com/signoz/foundry/internal/domain" foundryerrors "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/molding" + "github.com/signoz/foundry/internal/tooler" + "github.com/signoz/foundry/internal/tooler/dockercomposetooler" ) var _ rootcasting.Casting = (*dockerComposeCasting)(nil) @@ -99,39 +98,33 @@ func (casting *dockerComposeCasting) Forge(ctx context.Context, config installat return materials, nil } -func (casting *dockerComposeCasting) Cast(ctx context.Context, config installation.Casting, outputPath string) error { - casting.logger.InfoContext(ctx, "Executing commands for platform") - - // Check if compose file exists - composeFile := filepath.Join(outputPath, rootcasting.DeploymentDir, "compose.yaml") - if _, err := os.Stat(composeFile); os.IsNotExist(err) { - return foundryerrors.Newf(foundryerrors.TypeNotFound, "compose file does not exist at path: %s", composeFile) - } - - // Get the available docker compose command - composeCmd, err := getComposeCommand(ctx) +func (casting *dockerComposeCasting) Cast(ctx context.Context, config installation.Casting, outputPath string, toolers []tooler.Tooler) error { + compose, err := dockercomposetooler.Lookup(toolers) if err != nil { - casting.logger.ErrorContext(ctx, "Docker compose not available", slog.String("error", err.Error())) - return foundryerrors.Wrapf(err, foundryerrors.TypeNotFound, "docker compose not available") + return err + } + release := dockercomposetooler.Release{ + Release: domain.Release{Name: config.Metadata.Name, Owner: config.Labels()}, + File: filepath.Join(outputPath, rootcasting.DeploymentDir, strings.TrimSuffix(composeYAMLTemplate.Name(), ".gotmpl")), } - args := append(composeCmd[1:], "-f", composeFile, "up", "-d") - - casting.logger.DebugContext(ctx, "Running command", slog.String("command", strings.Join(append([]string{composeCmd[0]}, args...), " "))) - - cmd := exec.CommandContext(ctx, composeCmd[0], args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + return compose.Up(ctx, release) +} - err = cmd.Run() +// Melt removes the containers and networks; the volumes holding component +// data stay. +func (casting *dockerComposeCasting) Melt(ctx context.Context, config installation.Casting, outputPath string, toolers []tooler.Tooler) error { + compose, err := dockercomposetooler.Lookup(toolers) if err != nil { - casting.logger.ErrorContext(ctx, "Command execution failed", slog.String("error", err.Error())) return err } - casting.logger.InfoContext(ctx, "Command executed successfully") + release := dockercomposetooler.Release{ + Release: domain.Release{Name: config.Metadata.Name, Owner: config.Labels()}, + File: filepath.Join(outputPath, rootcasting.DeploymentDir, strings.TrimSuffix(composeYAMLTemplate.Name(), ".gotmpl")), + } - return nil + return compose.Down(ctx, release) } func getComposeMaterial(config *installation.Casting, path string) (domain.StructuredMaterial, error) { @@ -143,22 +136,3 @@ func getComposeMaterial(config *installation.Casting, path string) (domain.Struc return domain.NewYAMLMaterial(buf.Bytes(), path) } - -// getComposeCommand detects the available docker compose command. -// It checks for "docker compose" (newer, preferred) first, then falls back to "docker-compose" (legacy). -func getComposeCommand(ctx context.Context) ([]string, error) { - // Check "docker compose" first (newer, preferred) - if _, err := exec.LookPath("docker"); err == nil { - cmd := exec.CommandContext(ctx, "docker", "compose", "version") - if err := cmd.Run(); err == nil { - return []string{"docker", "compose"}, nil - } - } - - // Fallback to "docker-compose" (legacy) - if _, err := exec.LookPath("docker-compose"); err == nil { - return []string{"docker-compose"}, nil - } - - return nil, errors.New("neither 'docker compose' nor 'docker-compose' is available") -} diff --git a/internal/casting/dockercomposecasting/templates/compose.yaml.gotmpl b/internal/casting/dockercomposecasting/templates/compose.yaml.gotmpl index 2032ffb2..7f271539 100644 --- a/internal/casting/dockercomposecasting/templates/compose.yaml.gotmpl +++ b/internal/casting/dockercomposecasting/templates/compose.yaml.gotmpl @@ -5,6 +5,10 @@ services: container_name: {{ $.Metadata.Name }}-telemetrykeeper-{{ $.Spec.TelemetryKeeper.Kind }}-{{ $replicaIdx }} image: {{ $.Spec.TelemetryKeeper.Spec.Image }} restart: unless-stopped + labels: + {{- range $key, $value := $.Labels }} + {{ $key }}: {{ $value | quote }} + {{- end }} networks: - {{ $.Metadata.Name }}-network {{- if eq $.Spec.TelemetryKeeper.Kind.String "zookeeper" }} @@ -68,6 +72,10 @@ services: {{- end }} image: {{ $.Spec.TelemetryStore.Spec.Image }} restart: unless-stopped + labels: + {{- range $key, $value := $.Labels }} + {{ $key }}: {{ $value | quote }} + {{- end }} networks: - {{ $.Metadata.Name }}-network environment: @@ -100,6 +108,10 @@ services: container_name: {{ $.Metadata.Name }}-telemetrystore-{{ $.Spec.TelemetryStore.Kind }}-user-scripts image: {{ $.Spec.TelemetryStore.Spec.Image }} restart: on-failure + labels: + {{- range $key, $value := $.Labels }} + {{ $key }}: {{ $value | quote }} + {{- end }} networks: - {{ $.Metadata.Name }}-network volumes: @@ -122,6 +134,10 @@ services: container_name: {{ $.Metadata.Name }}-metastore-{{ $.Spec.MetaStore.Kind }}-{{ $replicaIdx }} image: {{ $.Spec.MetaStore.Spec.Image }} restart: unless-stopped + labels: + {{- range $key, $value := $.Labels }} + {{ $key }}: {{ $value | quote }} + {{- end }} networks: - {{ $.Metadata.Name }}-network {{- if $.Spec.MetaStore.Spec.Env }} @@ -146,6 +162,10 @@ services: ingester: image: {{ $.Spec.Ingester.Spec.Image }} restart: unless-stopped + labels: + {{- range $key, $value := $.Labels }} + {{ $key }}: {{ $value | quote }} + {{- end }} networks: {{ $.Metadata.Name }}-network: aliases: @@ -185,6 +205,10 @@ services: container_name: {{ $.Metadata.Name }}-signoz-{{ $replicaIdx }} image: {{ $.Spec.Signoz.Spec.Image }} restart: unless-stopped + labels: + {{- range $key, $value := $.Labels }} + {{ $key }}: {{ $value | quote }} + {{- end }} networks: - {{ $.Metadata.Name }}-network {{- if $.Spec.Signoz.Spec.Env }} @@ -216,6 +240,10 @@ services: mcp: image: {{ $.Spec.MCP.Spec.Image }} restart: unless-stopped + labels: + {{- range $key, $value := $.Labels }} + {{ $key }}: {{ $value | quote }} + {{- end }} networks: {{ $.Metadata.Name }}-network: aliases: @@ -240,6 +268,10 @@ services: container_name: {{ $.Metadata.Name }}-telemetrystore-migrator image: {{ $.Spec.Ingester.Spec.Image }} restart: on-failure + labels: + {{- range $key, $value := $.Labels }} + {{ $key }}: {{ $value | quote }} + {{- end }} networks: - {{ $.Metadata.Name }}-network entrypoint: diff --git a/internal/casting/dockerswarmcasting/casting.go b/internal/casting/dockerswarmcasting/casting.go index e9c8dc80..b3f1a050 100644 --- a/internal/casting/dockerswarmcasting/casting.go +++ b/internal/casting/dockerswarmcasting/casting.go @@ -4,17 +4,16 @@ import ( "bytes" "context" "log/slog" - "os" - "os/exec" "path/filepath" "strings" - "time" "github.com/signoz/foundry/api/v1alpha1/installation" rootcasting "github.com/signoz/foundry/internal/casting" "github.com/signoz/foundry/internal/domain" "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/molding" + "github.com/signoz/foundry/internal/tooler" + "github.com/signoz/foundry/internal/tooler/dockerswarmtooler" ) var _ rootcasting.Casting = (*dockerSwarmCasting)(nil) @@ -95,36 +94,34 @@ func (casting *dockerSwarmCasting) Forge(ctx context.Context, config installatio return materials, nil } -func (casting *dockerSwarmCasting) Cast(ctx context.Context, config installation.Casting, outputPath string) error { - casting.logger.InfoContext(ctx, "Deploying stack to Docker Swarm") - - composeFile := filepath.Join(outputPath, rootcasting.DeploymentDir, "compose.yaml") - if _, err := os.Stat(composeFile); os.IsNotExist(err) { - return errors.Newf(errors.TypeNotFound, "compose file does not exist at path: %s", composeFile) +func (casting *dockerSwarmCasting) Cast(ctx context.Context, config installation.Casting, outputPath string, toolers []tooler.Tooler) error { + swarm, err := dockerswarmtooler.Lookup(toolers) + if err != nil { + return err } - runctx, cancel := context.WithTimeout(ctx, 5*time.Minute) - defer cancel() - - args := []string{"stack", "deploy", "-d", "-c", composeFile} - - args = append(args, config.Metadata.Name) - - casting.logger.DebugContext(runctx, "Running command", slog.String("command", strings.Join(append([]string{"docker"}, args...), " "))) + release := dockerswarmtooler.Release{ + Release: domain.Release{Name: config.Metadata.Name, Owner: config.Labels()}, + File: filepath.Join(outputPath, rootcasting.DeploymentDir, strings.TrimSuffix(composeYAMLTemplate.Name(), ".gotmpl")), + } - cmd := exec.CommandContext(runctx, "docker", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + return swarm.Up(ctx, release) +} - err := cmd.Run() +// Melt removes the stack's services and networks; the volumes holding +// component data stay. +func (casting *dockerSwarmCasting) Melt(ctx context.Context, config installation.Casting, outputPath string, toolers []tooler.Tooler) error { + swarm, err := dockerswarmtooler.Lookup(toolers) if err != nil { - casting.logger.ErrorContext(runctx, "Stack deploy failed", slog.String("error", err.Error())) return err } - casting.logger.InfoContext(runctx, "Stack deployed successfully") + release := dockerswarmtooler.Release{ + Release: domain.Release{Name: config.Metadata.Name, Owner: config.Labels()}, + File: filepath.Join(outputPath, rootcasting.DeploymentDir, strings.TrimSuffix(composeYAMLTemplate.Name(), ".gotmpl")), + } - return nil + return swarm.Down(ctx, release) } func getComposeMaterial(config *installation.Casting, path string) (domain.StructuredMaterial, error) { diff --git a/internal/casting/ecsterraformcasting/casting.go b/internal/casting/ecsterraformcasting/casting.go index f47205d8..55daf73d 100644 --- a/internal/casting/ecsterraformcasting/casting.go +++ b/internal/casting/ecsterraformcasting/casting.go @@ -3,17 +3,15 @@ package ecsterraformcasting import ( "context" "log/slog" - "os" - "os/exec" "path/filepath" - "strings" - "time" "github.com/signoz/foundry/api/v1alpha1/installation" rootcasting "github.com/signoz/foundry/internal/casting" "github.com/signoz/foundry/internal/domain" "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/molding" + "github.com/signoz/foundry/internal/tooler" + "github.com/signoz/foundry/internal/tooler/terraformtooler" ) var _ rootcasting.Casting = (*ecsCasting)(nil) @@ -155,45 +153,18 @@ func (c *ecsCasting) Forge(ctx context.Context, config installation.Casting, pou return materials, nil } -func (c *ecsCasting) Cast(ctx context.Context, config installation.Casting, outputPath string) error { +func (c *ecsCasting) Cast(ctx context.Context, config installation.Casting, outputPath string, toolers []tooler.Tooler) error { c.logger.InfoContext(ctx, "Running Terraform for ECS deployment") - deploymentDir := filepath.Join(outputPath, rootcasting.DeploymentDir) - - // Verify terraform files exist - if _, err := os.Stat(filepath.Join(deploymentDir, "main.tf.json")); os.IsNotExist(err) { - return errors.Newf(errors.TypeNotFound, "terraform files do not exist at path: %s; run forge first", deploymentDir) - } - - // Create a context with 10-minute timeout (terraform can be slow) - runctx, cancel := context.WithTimeout(ctx, 10*time.Minute) - defer cancel() - - // Run terraform init - c.logger.InfoContext(runctx, "Running terraform init") - initCmd := exec.CommandContext(runctx, "terraform", "-chdir="+deploymentDir, "init") - initCmd.Stdout = os.Stdout - initCmd.Stderr = os.Stderr - if err := initCmd.Run(); err != nil { - c.logger.ErrorContext(runctx, "terraform init failed", slog.String("error", err.Error())) - return errors.Wrapf(err, errors.TypeInternal, "terraform init failed") + terraform, err := terraformtooler.Lookup(toolers) + if err != nil { + return err } - // Run terraform apply - c.logger.InfoContext(runctx, "Running terraform apply") - args := []string{"-chdir=" + deploymentDir, "apply", "-auto-approve"} - c.logger.DebugContext(runctx, "Running command", slog.String("command", "terraform "+strings.Join(args, " "))) - - applyCmd := exec.CommandContext(runctx, "terraform", args...) - applyCmd.Stdout = os.Stdout - applyCmd.Stderr = os.Stderr - if err := applyCmd.Run(); err != nil { - c.logger.ErrorContext(runctx, "terraform apply failed", slog.String("error", err.Error())) - return errors.Wrapf(err, errors.TypeInternal, "terraform apply failed") - } - - c.logger.InfoContext(runctx, "Terraform apply completed successfully") - return nil + return terraform.Apply(ctx, terraformtooler.Release{ + Release: domain.Release{Name: config.Metadata.Name, Owner: config.Labels()}, + Root: filepath.Join(outputPath, rootcasting.DeploymentDir), + }) } // getMaterials renders all module templates and returns them as JSONMaterials. @@ -221,3 +192,8 @@ func getMaterials(config *installation.Casting) ([]domain.StructuredMaterial, er return materials, nil } + +// Melt is not implemented for this casting yet. +func (c *ecsCasting) Melt(ctx context.Context, config installation.Casting, outputPath string, _ []tooler.Tooler) error { + return errors.Newf(errors.TypeUnsupported, "melt is not implemented for this casting yet") +} diff --git a/internal/casting/infrastructure/casting.go b/internal/casting/infrastructure/casting.go index fdbf1de2..8a07239e 100644 --- a/internal/casting/infrastructure/casting.go +++ b/internal/casting/infrastructure/casting.go @@ -6,10 +6,15 @@ import ( "github.com/signoz/foundry/api/v1alpha1/infrastructure" infrastructuremolding "github.com/signoz/foundry/internal/molding/infrastructure" "github.com/signoz/foundry/internal/pourer" + "github.com/signoz/foundry/internal/tooler" ) 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 + + Cast(ctx context.Context, config infrastructure.Casting, outputPath string, p *pourer.Pourer, toolers []tooler.Tooler) error + + Melt(ctx context.Context, config infrastructure.Casting, outputPath string, p *pourer.Pourer, toolers []tooler.Tooler) error } diff --git a/internal/casting/infrastructure/planner.go b/internal/casting/infrastructure/planner.go index 8257615d..93c20927 100644 --- a/internal/casting/infrastructure/planner.go +++ b/internal/casting/infrastructure/planner.go @@ -56,9 +56,9 @@ func NewPlanner(ctx context.Context, c *infrastructure.Casting, logger *slog.Log config: c, logger: logger, casting: castingStrategy, - toolers: toolers, enricher: enricher, moldings: moldings, + toolers: toolers, }, nil } @@ -99,7 +99,13 @@ func (p *Planner) Forge(ctx context.Context, target string) ([]domain.Material, } 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()))) + return p.casting.Cast(ctx, *p.config, poursPath, pourer.New(strings.ToLower(p.config.Kind().String())), p.toolers) } -func (p *Planner) Toolers() []tooler.Tooler { return p.toolers } +func (p *Planner) Melt(ctx context.Context, poursPath string) error { + return p.casting.Melt(ctx, *p.config, poursPath, pourer.New(strings.ToLower(p.config.Kind().String())), p.toolers) +} + +func (p *Planner) Toolers() []tooler.Tooler { + return p.toolers +} diff --git a/internal/casting/infrastructure/registry.go b/internal/casting/infrastructure/registry.go index 110c1b53..23da6254 100644 --- a/internal/casting/infrastructure/registry.go +++ b/internal/casting/infrastructure/registry.go @@ -14,6 +14,7 @@ type CastingItem struct { } type Registry struct { + // Castings for the different deployments. castings map[v1alpha1.TypeDeployment]CastingItem } @@ -41,7 +42,7 @@ func (registry *Registry) Casting(deployment v1alpha1.TypeDeployment) (Casting, 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 nil, foundryerrors.Newf(foundryerrors.TypeUnsupported, "infrastructure deployment '%+v' is not supported, raise an issue at https://github.com/signoz/foundry/issues to request support for this deployment", deployment) } return item.Toolers, nil } diff --git a/internal/casting/installation/planner.go b/internal/casting/installation/planner.go index c6cc0294..485a5a27 100644 --- a/internal/casting/installation/planner.go +++ b/internal/casting/installation/planner.go @@ -104,7 +104,11 @@ func (p *Planner) Forge(ctx context.Context, target string) ([]domain.Material, } func (p *Planner) Cast(ctx context.Context, poursPath string) error { - return p.casting.Cast(ctx, *p.config, poursPath) + return p.casting.Cast(ctx, *p.config, poursPath, p.toolers) +} + +func (p *Planner) Melt(ctx context.Context, poursPath string) error { + return p.casting.Melt(ctx, *p.config, poursPath, p.toolers) } func (p *Planner) Toolers() []tooler.Tooler { diff --git a/internal/casting/installation/registry.go b/internal/casting/installation/registry.go index bd6661f2..fd516107 100644 --- a/internal/casting/installation/registry.go +++ b/internal/casting/installation/registry.go @@ -18,9 +18,8 @@ import ( "github.com/signoz/foundry/internal/tooler" "github.com/signoz/foundry/internal/tooler/dockercomposetooler" "github.com/signoz/foundry/internal/tooler/dockerswarmtooler" - "github.com/signoz/foundry/internal/tooler/dockertooler" "github.com/signoz/foundry/internal/tooler/helmtooler" - "github.com/signoz/foundry/internal/tooler/kubectltooler" + "github.com/signoz/foundry/internal/tooler/kubetooler" "github.com/signoz/foundry/internal/tooler/systemdtooler" "github.com/signoz/foundry/internal/tooler/terraformtooler" ) @@ -47,28 +46,28 @@ func NewRegistry(logger *slog.Logger) *Registry { Flavor: v1alpha1.FlavorCompose, }: { Casting: dockercomposecasting.New(logger), - Toolers: []tooler.Tooler{dockertooler.New(), dockercomposetooler.New()}, + Toolers: []tooler.Tooler{dockercomposetooler.New(logger)}, }, { Mode: v1alpha1.ModeSystemd, Flavor: v1alpha1.FlavorBinary, }: { Casting: systemdcasting.New(logger), - Toolers: []tooler.Tooler{systemdtooler.New()}, + Toolers: []tooler.Tooler{systemdtooler.New(logger)}, }, { Mode: v1alpha1.ModeDocker, Flavor: v1alpha1.FlavorSwarm, }: { Casting: dockerswarmcasting.New(logger), - Toolers: []tooler.Tooler{dockertooler.New(), dockerswarmtooler.New()}, + Toolers: []tooler.Tooler{dockerswarmtooler.New(logger)}, }, { Mode: v1alpha1.ModeKubernetes, Flavor: v1alpha1.FlavorKustomize, }: { Casting: kuberneteskustomizecasting.New(logger), - Toolers: []tooler.Tooler{kubectltooler.New()}, + Toolers: []tooler.Tooler{kubetooler.New(logger)}, }, { Platform: v1alpha1.PlatformRender, @@ -94,14 +93,14 @@ func NewRegistry(logger *slog.Logger) *Registry { Mode: v1alpha1.ModeEC2, }: { Casting: ecsterraformcasting.New(logger), - Toolers: []tooler.Tooler{terraformtooler.New()}, + Toolers: []tooler.Tooler{terraformtooler.New(logger)}, }, { Mode: v1alpha1.ModeKubernetes, Flavor: v1alpha1.FlavorHelm, }: { Casting: kuberneteshelmcasting.New(logger), - Toolers: []tooler.Tooler{helmtooler.New()}, + Toolers: []tooler.Tooler{helmtooler.New(logger)}, }, }, } diff --git a/internal/casting/kuberneteshelmcasting/casting.go b/internal/casting/kuberneteshelmcasting/casting.go index b9a66662..7221aa97 100644 --- a/internal/casting/kuberneteshelmcasting/casting.go +++ b/internal/casting/kuberneteshelmcasting/casting.go @@ -3,22 +3,17 @@ package kuberneteshelmcasting import ( "bytes" "context" - "fmt" "log/slog" "os" "path/filepath" - "time" "github.com/signoz/foundry/api/v1alpha1/installation" rootcasting "github.com/signoz/foundry/internal/casting" "github.com/signoz/foundry/internal/domain" "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/molding" - "helm.sh/helm/v3/pkg/action" - "helm.sh/helm/v3/pkg/chart/loader" - "helm.sh/helm/v3/pkg/cli" - "helm.sh/helm/v3/pkg/getter" - "helm.sh/helm/v3/pkg/repo" + "github.com/signoz/foundry/internal/tooler" + "github.com/signoz/foundry/internal/tooler/helmtooler" "sigs.k8s.io/yaml" ) @@ -26,7 +21,6 @@ const ( helmChartRepoUrl = "https://charts.signoz.io" helmChartRepoName = "signoz" helmChart = "signoz/signoz" - helmDeployTimeout = 10 * time.Minute annotationChart = "foundry.signoz.io/kubernetes-helm-casting-chart" annotationRepoURL = "foundry.signoz.io/kubernetes-helm-casting-repo-url" @@ -69,156 +63,102 @@ func (c *helmCasting) Forge(ctx context.Context, config installation.Casting, po return []domain.Material{valuesMaterial}, nil } -func (c *helmCasting) Cast(ctx context.Context, config installation.Casting, poursPath string) error { +func (c *helmCasting) Cast(ctx context.Context, config installation.Casting, poursPath string, toolers []tooler.Tooler) error { + helm, err := helmtooler.Lookup(toolers) + if err != nil { + return err + } + + release, err := c.release(config, poursPath) + if err != nil { + return err + } + + c.logger.InfoContext(ctx, "deploying with helm", + slog.String("release", release.Name), + slog.String("namespace", release.Namespace), + slog.String("chart", release.Chart), + ) + return helm.Upgrade(ctx, release) +} + +func (c *helmCasting) Melt(ctx context.Context, config installation.Casting, poursPath string, toolers []tooler.Tooler) error { + helm, err := helmtooler.Lookup(toolers) + if err != nil { + return err + } + + c.logger.InfoContext(ctx, "removing helm release", + slog.String("release", config.Metadata.Name), + slog.String("namespace", config.Metadata.Name), + ) + + return helm.Uninstall(ctx, helmtooler.Release{ + Release: domain.Release{Name: config.Metadata.Name, Owner: config.Labels()}, + Namespace: config.Metadata.Name, + }) +} + +// release resolves the forged values and the chart the deploy uses: a local +// chart when the casting forged one, the signoz repo otherwise. +func (c *helmCasting) release(config installation.Casting, poursPath string) (helmtooler.Release, error) { valuesFile := filepath.Join(poursPath, rootcasting.DeploymentDir, "values.yaml") if _, err := os.Stat(valuesFile); os.IsNotExist(err) { - return errors.Newf(errors.TypeNotFound, "values.yaml does not exist at path %s, run 'forge' first", valuesFile) + return helmtooler.Release{}, errors.Newf(errors.TypeNotFound, "values.yaml does not exist at path %s, run 'forge' first", valuesFile) } valuesBytes, err := os.ReadFile(valuesFile) if err != nil { - return errors.Wrapf(err, errors.TypeInternal, "failed to read values file") + return helmtooler.Release{}, errors.Wrapf(err, errors.TypeInternal, "failed to read values file") } vals := map[string]any{} if err := yaml.Unmarshal(valuesBytes, &vals); err != nil { - return errors.Wrapf(err, errors.TypeInvalidInput, "failed to parse values") + return helmtooler.Release{}, errors.Wrapf(err, errors.TypeInvalidInput, "failed to parse values") } - settings := cli.New() - settings.SetNamespace(config.Metadata.Name) - - actionConfig := new(action.Configuration) - if err := actionConfig.Init(settings.RESTClientGetter(), config.Metadata.Name, os.Getenv("HELM_DRIVER"), func(format string, v ...any) { - c.logger.Debug(fmt.Sprintf(format, v...)) - }); err != nil { - return errors.Wrapf(err, errors.TypeInternal, "failed to initialize helm action config") + release := helmtooler.Release{ + Release: domain.Release{Name: config.Metadata.Name, Owner: config.Labels()}, + Namespace: config.Metadata.Name, + Values: vals, } - var chartRef string if c.shouldForgeChart(&config) { - chartRef = filepath.Join(poursPath, rootcasting.DeploymentDir, "chart", "signoz") - if _, err := os.Stat(chartRef); os.IsNotExist(err) { - return errors.Newf(errors.TypeNotFound, "local chart not found at %s, run 'forge' first with %s annotation set to 'true'", chartRef, annotationForgeChart) - } - c.logger.InfoContext(ctx, "Installing from local chart", slog.String("path", chartRef)) - } else { - repoURL := helmChartRepoUrl - if config.Metadata.Annotations != nil { - if u := config.Metadata.Annotations[annotationRepoURL]; u != "" { - repoURL = u - } - } - - chartRef = helmChart - if config.Metadata.Annotations != nil { - if ch := config.Metadata.Annotations[annotationChart]; ch != "" { - chartRef = ch - } + chartPath := filepath.Join(poursPath, rootcasting.DeploymentDir, "chart", "signoz") + if _, err := os.Stat(chartPath); os.IsNotExist(err) { + return helmtooler.Release{}, errors.Newf(errors.TypeNotFound, "local chart not found at %s, run 'forge' first with %s annotation set to 'true'", chartPath, annotationForgeChart) } - repoName := helmChartRepoName - if config.Metadata.Annotations != nil { - if ch := config.Metadata.Annotations[annotationRepoName]; ch != "" { - chartRef = ch - } - } + release.Chart = chartPath - c.logger.InfoContext(ctx, "Adding Helm repo", slog.String("name", repoName), slog.String("url", repoURL), slog.String("chart", chartRef)) - if err := addHelmRepo(settings, repoName, repoURL); err != nil { - return errors.Wrapf(err, errors.TypeInternal, "failed to add helm repo") - } + return release, nil } - c.logger.InfoContext(ctx, "Deploying with Helm", - slog.String("release", config.Metadata.Name), - slog.String("chart", chartRef), - slog.String("namespace", config.Metadata.Name), - ) - - histClient := action.NewHistory(actionConfig) - histClient.Max = 1 - _, err = histClient.Run(config.Metadata.Name) + release.Chart = helmChart + release.Repo = helmtooler.Repo{Name: helmChartRepoName, URL: helmChartRepoUrl} - if err != nil { - install := action.NewInstall(actionConfig) - install.ReleaseName = config.Metadata.Name - install.Namespace = config.Metadata.Name - install.CreateNamespace = true - install.Wait = true - install.Timeout = helmDeployTimeout - - chartPath, err := install.LocateChart(chartRef, settings) - if err != nil { - return errors.Wrapf(err, errors.TypeInternal, "failed to locate chart") + if config.Metadata.Annotations != nil { + if url := config.Metadata.Annotations[annotationRepoURL]; url != "" { + release.Repo.URL = url } - chart, err := loader.Load(chartPath) - if err != nil { - return errors.Wrapf(err, errors.TypeInternal, "failed to load chart") - } - - if _, err := install.RunWithContext(ctx, chart, vals); err != nil { - return errors.Wrapf(err, errors.TypeInternal, "helm install failed") - } - } else { - upgrade := action.NewUpgrade(actionConfig) - upgrade.Namespace = config.Metadata.Name - upgrade.Wait = true - upgrade.Timeout = helmDeployTimeout - - chartPath, err := upgrade.LocateChart(chartRef, settings) - if err != nil { - return errors.Wrapf(err, errors.TypeInternal, "failed to locate chart") + if chart := config.Metadata.Annotations[annotationChart]; chart != "" { + release.Chart = chart } - chart, err := loader.Load(chartPath) - if err != nil { - return errors.Wrapf(err, errors.TypeInternal, "failed to load chart") - } - - if _, err := upgrade.RunWithContext(ctx, config.Metadata.Name, chart, vals); err != nil { - return errors.Wrapf(err, errors.TypeInternal, "helm upgrade failed") + if name := config.Metadata.Annotations[annotationRepoName]; name != "" { + release.Repo.Name = name } } - c.logger.InfoContext(ctx, "Helm deployment complete", - slog.String("release", config.Metadata.Name), - slog.String("namespace", config.Metadata.Name), - ) - return nil + return release, nil } func (c *helmCasting) shouldForgeChart(config *installation.Casting) bool { if config.Metadata.Annotations == nil { return false } - return config.Metadata.Annotations[annotationForgeChart] == "true" -} - -func addHelmRepo(settings *cli.EnvSettings, name, url string) error { - repoFile := settings.RepositoryConfig - repoEntry := &repo.Entry{ - Name: name, - URL: url, - } - - r, err := repo.NewChartRepository(repoEntry, getter.All(settings)) - if err != nil { - return errors.Wrapf(err, errors.TypeInternal, "failed to create chart repository") - } - r.CachePath = settings.RepositoryCache - if _, err := r.DownloadIndexFile(); err != nil { - return errors.Wrapf(err, errors.TypeInternal, "failed to download repo index") - } - - f, err := repo.LoadFile(repoFile) - if err != nil { - f = repo.NewFile() - } - - f.Update(repoEntry) - return f.WriteFile(repoFile, 0644) + return config.Metadata.Annotations[annotationForgeChart] == "true" } diff --git a/internal/casting/kuberneteskustomizecasting/casting.go b/internal/casting/kuberneteskustomizecasting/casting.go index 1cbef6bc..86cf2f8f 100644 --- a/internal/casting/kuberneteskustomizecasting/casting.go +++ b/internal/casting/kuberneteskustomizecasting/casting.go @@ -2,19 +2,17 @@ package kuberneteskustomizecasting import ( "context" - "fmt" "log/slog" - "os" - "os/exec" "path/filepath" "strings" - "time" "github.com/signoz/foundry/api/v1alpha1/installation" rootcasting "github.com/signoz/foundry/internal/casting" "github.com/signoz/foundry/internal/domain" "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/molding" + "github.com/signoz/foundry/internal/tooler" + "github.com/signoz/foundry/internal/tooler/kubetooler" ) var _ rootcasting.Casting = (*kustomizeCasting)(nil) @@ -125,71 +123,52 @@ func (c *kustomizeCasting) Forge(ctx context.Context, cfg installation.Casting, return materials, nil } -// operators/ is its own tier, outside the root kustomization: it is applied -// first and its CRDs waited on, since one pass would post the +// Cast applies the operators tier before the root: one pass would post the // ClickHouseInstallation before its kind exists. -var clickhouseCRDs = []string{ - "clickhouseinstallations.clickhouse.altinity.com", - "clickhouseinstallationtemplates.clickhouse.altinity.com", - "clickhouseoperatorconfigurations.clickhouse.altinity.com", - "clickhousekeeperinstallations.clickhouse-keeper.altinity.com", -} - -func (c *kustomizeCasting) Cast(ctx context.Context, config installation.Casting, poursPath string) error { - c.logger.InfoContext(ctx, "Applying kustomize manifests") - - kustomizeDir := filepath.Join(poursPath, rootcasting.DeploymentDir) - if _, err := os.Stat(filepath.Join(kustomizeDir, "kustomization.yaml")); os.IsNotExist(err) { - return errors.Newf(errors.TypeNotFound, "kustomization.yaml does not exist at path: %s, run 'forge' first", kustomizeDir) +func (c *kustomizeCasting) Cast(ctx context.Context, config installation.Casting, poursPath string, toolers []tooler.Tooler) error { + kube, err := kubetooler.Lookup(toolers) + if err != nil { + return err } - runctx, cancel := context.WithTimeout(ctx, 5*time.Minute) - defer cancel() + c.logger.InfoContext(ctx, "applying kustomize manifests", + slog.String("release", config.Metadata.Name), + slog.String("namespace", config.Metadata.Name), + ) if needsClickhouseOperator(&config) { - if err := c.kubectl(runctx, "apply", "-k", filepath.Join(kustomizeDir, "operators", "clickhouse-operator")); err != nil { - return errors.Wrapf(err, errors.TypeInternal, "failed to apply clickhouse-operator") - } + operators := c.release(config, poursPath) + operators.Dir = filepath.Join(operators.Dir, "operators", "clickhouse-operator") - args := []string{"wait", "--for=condition=Established", "--timeout=60s"} - for _, crd := range clickhouseCRDs { - args = append(args, "crd/"+crd) - } - if err := c.kubectl(runctx, args...); err != nil { - return errors.Wrapf(err, errors.TypeInternal, "failed waiting for clickhouse CRDs to be established") + if err := kube.Apply(ctx, operators); err != nil { + return err } } - // A Job's pod template is immutable, so a re-cast with a changed migrator - // (image, DSN) would be rejected; the finished run is replaced, not patched. - if config.Spec.TelemetryStore.Spec.IsEnabled() { - job := config.Metadata.Name + "-telemetrystore-migrator" - if err := c.kubectl(runctx, "delete", "job", job, "--namespace", config.Metadata.Name, "--ignore-not-found"); err != nil { - return errors.Wrapf(err, errors.TypeInternal, "failed to delete job %q", job) - } - } + return kube.Apply(ctx, c.release(config, poursPath)) +} - if err := c.kubectl(runctx, "apply", "-k", kustomizeDir); err != nil { - return errors.Wrapf(err, errors.TypeInternal, "kubectl apply -k failed") +// Melt leaves the namespace and the definitions standing: the tooler keeps +// what carries data or is shared with every other release in the cluster. +func (c *kustomizeCasting) Melt(ctx context.Context, config installation.Casting, poursPath string, toolers []tooler.Tooler) error { + kube, err := kubetooler.Lookup(toolers) + if err != nil { + return err } - c.logger.InfoContext(runctx, "Kustomize manifests applied successfully") - return nil + return kube.Delete(ctx, c.release(config, poursPath)) } -func (c *kustomizeCasting) kubectl(ctx context.Context, args ...string) error { - cmd := exec.CommandContext(ctx, "kubectl", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - c.logger.DebugContext(ctx, "Running command", - slog.String("command", fmt.Sprintf("kubectl %s", strings.Join(args, " ")))) +func (c *kustomizeCasting) release(config installation.Casting, poursPath string) kubetooler.Release { + return kubetooler.Release{ + Release: domain.Release{Name: config.Metadata.Name, Owner: config.Labels()}, + Namespace: config.Metadata.Name, + Dir: filepath.Join(poursPath, rootcasting.DeploymentDir), - if err := cmd.Run(); err != nil { - c.logger.ErrorContext(ctx, "kubectl failed", slog.String("error", err.Error())) - return err + // The Kind, not the release, so an Installation's apply never contends + // with a CollectionAgent's over an object they share. + FieldManager: "foundry-" + strings.ToLower(config.Kind().String()), } - return nil } // The Altinity operator serves both the CHI and the CHK. diff --git a/internal/casting/kuberneteskustomizecasting/templates/kustomization.yaml.gotmpl b/internal/casting/kuberneteskustomizecasting/templates/kustomization.yaml.gotmpl index df42c33a..aa0261ef 100644 --- a/internal/casting/kuberneteskustomizecasting/templates/kustomization.yaml.gotmpl +++ b/internal/casting/kuberneteskustomizecasting/templates/kustomization.yaml.gotmpl @@ -3,6 +3,13 @@ kind: Kustomization namespace: {{ $.Metadata.Name }} +labels: +- includeSelectors: false + pairs: +{{- range $key, $value := $.Labels }} + {{ $key }}: {{ $value }} +{{- end }} + resources: - namespace.yaml {{- if derefBool $.Spec.TelemetryStore.Spec.Enabled }} diff --git a/internal/casting/railwaytemplatecasting/casting.go b/internal/casting/railwaytemplatecasting/casting.go index d94ce1d9..87fc4478 100644 --- a/internal/casting/railwaytemplatecasting/casting.go +++ b/internal/casting/railwaytemplatecasting/casting.go @@ -11,6 +11,7 @@ import ( "github.com/signoz/foundry/internal/domain" "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/molding" + "github.com/signoz/foundry/internal/tooler" ) var _ casting.Casting = (*railwayTemplateCasting)(nil) @@ -160,11 +161,18 @@ func (c *railwayTemplateCasting) Forge(ctx context.Context, config installation. return materials, nil } -func (c *railwayTemplateCasting) Cast(ctx context.Context, config installation.Casting, poursPath string) error { +func (c *railwayTemplateCasting) Cast(ctx context.Context, config installation.Casting, poursPath string, _ []tooler.Tooler) error { c.logger.InfoContext(ctx, "Please use the template.") return nil } +// Melt tells the operator where to remove the deployment: foundry does not +// drive Railway. +func (c *railwayTemplateCasting) Melt(ctx context.Context, config installation.Casting, poursPath string, _ []tooler.Tooler) error { + c.logger.InfoContext(ctx, "Remove the services from Railway directly; foundry does not manage Railway resources.") + return nil +} + func getRailwayMaterial(config *installation.Casting) ([]domain.StructuredMaterial, error) { var materials []domain.StructuredMaterial diff --git a/internal/casting/rendercasting/casting.go b/internal/casting/rendercasting/casting.go index e47ccef9..828818c3 100644 --- a/internal/casting/rendercasting/casting.go +++ b/internal/casting/rendercasting/casting.go @@ -12,6 +12,7 @@ import ( "github.com/signoz/foundry/internal/domain" "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/molding" + "github.com/signoz/foundry/internal/tooler" ) var _ casting.Casting = (*renderCasting)(nil) @@ -111,7 +112,7 @@ func (c *renderCasting) Forge(ctx context.Context, config installation.Casting, return materials, nil } -func (c *renderCasting) Cast(ctx context.Context, config installation.Casting, poursPath string) error { +func (c *renderCasting) Cast(ctx context.Context, config installation.Casting, poursPath string, _ []tooler.Tooler) error { c.logger.InfoContext(ctx, "Please run 'forge' first to generate the Render Casting", slog.String("pours_path", poursPath)) c.logger.InfoContext(ctx, "After forging, deploy render.yaml to Render using Infrastructure as Code", @@ -119,6 +120,14 @@ func (c *renderCasting) Cast(ctx context.Context, config installation.Casting, p return nil } +// Melt tells the operator where to remove the deployment: foundry does not +// drive Render. +func (c *renderCasting) Melt(ctx context.Context, config installation.Casting, poursPath string, _ []tooler.Tooler) error { + c.logger.InfoContext(ctx, "Remove the services from Render directly; foundry does not manage Render resources", + slog.String("Docs", "https://render.com/docs/infrastructure-as-code#setup")) + return nil +} + func getRenderMaterial(config *installation.Casting, path string) (domain.StructuredMaterial, error) { buf := bytes.NewBuffer(nil) err := renderYAMLTemplate.Execute(buf, config) diff --git a/internal/casting/systemdcasting/casting.go b/internal/casting/systemdcasting/casting.go index 2f2a171b..656b94ff 100644 --- a/internal/casting/systemdcasting/casting.go +++ b/internal/casting/systemdcasting/casting.go @@ -9,7 +9,7 @@ import ( "github.com/signoz/foundry/internal/errors" "github.com/signoz/foundry/internal/molding" "github.com/signoz/foundry/internal/tooler" - "github.com/signoz/foundry/internal/tooler/binarytooler" + "github.com/signoz/foundry/internal/tooler/systemdtooler" "os" "os/exec" @@ -62,7 +62,7 @@ func (c *systemdCasting) Forge(ctx context.Context, cfg installation.Casting, po return materials, nil } -func (c *systemdCasting) Cast(ctx context.Context, config installation.Casting, poursPath string) error { +func (c *systemdCasting) Cast(ctx context.Context, config installation.Casting, poursPath string, toolers []tooler.Tooler) error { ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) defer cancel() @@ -75,6 +75,11 @@ func (c *systemdCasting) Cast(ctx context.Context, config installation.Casting, return nil } + systemd, err := systemdtooler.Lookup(toolers) + if err != nil { + return err + } + if err := c.provision(ctx, &config, poursPath); err != nil { return err } @@ -87,7 +92,12 @@ func (c *systemdCasting) Cast(ctx context.Context, config installation.Casting, if err := c.initializeTelemetryStore(ctx, &config); err != nil { return err } - if err := c.startUnits(ctx, units); err != nil { + + release := systemdtooler.Release{ + Release: domain.Release{Name: config.Metadata.Name, Owner: config.Labels()}, + Units: units, + } + if err := systemd.Up(ctx, release); err != nil { return err } @@ -95,6 +105,28 @@ func (c *systemdCasting) Cast(ctx context.Context, config installation.Casting, return nil } +func (c *systemdCasting) Melt(ctx context.Context, config installation.Casting, poursPath string, toolers []tooler.Tooler) error { + units, err := c.discoverUnits(poursPath) + if err != nil { + return err + } + if len(units) == 0 { + return nil + } + + systemd, err := systemdtooler.Lookup(toolers) + if err != nil { + return err + } + + release := systemdtooler.Release{ + Release: domain.Release{Name: config.Metadata.Name, Owner: config.Labels()}, + Units: units, + } + + return systemd.Down(ctx, release) +} + func (c *systemdCasting) forgeTelemetryKeeper(cfg *installation.Casting) ([]domain.Material, error) { if !cfg.Spec.TelemetryKeeper.Spec.IsEnabled() { return nil, nil @@ -346,7 +378,7 @@ func (c *systemdCasting) provision(ctx context.Context, config *installation.Cas } } - return c.validateBinaries(ctx, config) + return c.validateBinaries(config) } // copyDir copies all files from srcDir to dstDir. @@ -375,39 +407,42 @@ func (c *systemdCasting) copyDir(srcDir, dstDir string) error { // validateBinaries checks that every component binary the casting will exec // exists at its resolved path: the annotation override if set, otherwise the -// default. Each binary is verified through a binary tooler and all misses are -// aggregated into a single error. -func (c *systemdCasting) validateBinaries(ctx context.Context, config *installation.Casting) error { - var missing []string - var binaries []tooler.Tooler +// default. All misses are aggregated into a single error. +func (c *systemdCasting) validateBinaries(config *installation.Casting) error { + type binary struct { + name string + path string + } annotations := config.Metadata.Annotations + var binaries []binary if config.Spec.TelemetryKeeper.Spec.IsEnabled() && config.Spec.TelemetryKeeper.Kind == installation.TelemetryKeeperKindClickhouseKeeper { - binaries = append(binaries, binarytooler.New("clickhouse-keeper", installation.TelemetryKeeperClickHouseKeeperBinaryPath.Resolve(annotations))) + binaries = append(binaries, binary{"clickhouse-keeper", installation.TelemetryKeeperClickHouseKeeperBinaryPath.Resolve(annotations)}) } if config.Spec.TelemetryKeeper.Spec.IsEnabled() && config.Spec.TelemetryKeeper.Kind == installation.TelemetryKeeperKindZookeeper { - binaries = append(binaries, binarytooler.New("zookeeper", installation.TelemetryKeeperZookeeperBinaryPath.Resolve(annotations))) + binaries = append(binaries, binary{"zookeeper", installation.TelemetryKeeperZookeeperBinaryPath.Resolve(annotations)}) } if config.Spec.TelemetryStore.Spec.IsEnabled() { - binaries = append(binaries, binarytooler.New("clickhouse", installation.TelemetryStoreClickHouseBinaryPath.Resolve(annotations))) + binaries = append(binaries, binary{"clickhouse", installation.TelemetryStoreClickHouseBinaryPath.Resolve(annotations)}) } if config.Spec.MetaStore.Spec.IsEnabled() && config.Spec.MetaStore.Kind == installation.MetaStoreKindPostgres { - binaries = append(binaries, binarytooler.New("postgres", installation.MetaStorePostgresBinaryPath.Resolve(annotations))) + binaries = append(binaries, binary{"postgres", installation.MetaStorePostgresBinaryPath.Resolve(annotations)}) } if config.Spec.Signoz.Spec.IsEnabled() { - binaries = append(binaries, binarytooler.New("signoz", installation.SignozBinaryPath.Resolve(annotations))) + binaries = append(binaries, binary{"signoz", installation.SignozBinaryPath.Resolve(annotations)}) } if config.Spec.Ingester.Spec.IsEnabled() { - binaries = append(binaries, binarytooler.New("ingester", installation.IngesterBinaryPath.Resolve(annotations))) + binaries = append(binaries, binary{"ingester", installation.IngesterBinaryPath.Resolve(annotations)}) } if config.Spec.MCP.Spec.IsEnabled() { - binaries = append(binaries, binarytooler.New("mcp", installation.MCPBinaryPath.Resolve(annotations))) + binaries = append(binaries, binary{"mcp", installation.MCPBinaryPath.Resolve(annotations)}) } - for _, t := range binaries { - if err := t.Gauge(ctx); err != nil { - missing = append(missing, err.Error()) + var missing []string + for _, b := range binaries { + if !binaryExists(b.path) { + missing = append(missing, fmt.Sprintf("%s binary not found at %q", b.name, b.path)) } } @@ -417,6 +452,12 @@ func (c *systemdCasting) validateBinaries(ctx context.Context, config *installat return nil } +// binaryExists reports whether a component binary is present at path. +func binaryExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + // initializeMetaStore prepares the metastore's on-disk state before its unit // starts: bootstrapping postgres, or creating the sqlite data directory. func (c *systemdCasting) initializeMetaStore(ctx context.Context, config *installation.Casting) error { @@ -585,33 +626,6 @@ func (c *systemdCasting) cleanupPostgresInit(ctx context.Context, pgDataDir, pwf } } -// startUnits enables every unit (so dependency references resolve), reloads -// systemd to pick up the new unit files, then starts them. Ordering between -// units is handled by systemd via After=/Requires=. -func (c *systemdCasting) startUnits(ctx context.Context, units []string) error { - for _, unit := range units { - name := filepath.Base(unit) - c.logger.DebugContext(ctx, "enabling unit", slog.String("unit", name)) - if err := c.systemctl(ctx, "enable", unit); err != nil { - return errors.Wrapf(err, errors.TypeInternal, "failed to enable unit %s", name) - } - } - - if err := c.systemctl(ctx, "daemon-reload"); err != nil { - return errors.Wrapf(err, errors.TypeInternal, "systemd daemon-reload failed") - } - - for _, unit := range units { - name := filepath.Base(unit) - c.logger.InfoContext(ctx, "starting unit", slog.String("unit", name)) - if err := c.systemctl(ctx, "start", "--no-block", name); err != nil { - return errors.Wrapf(err, errors.TypeInternal, "failed to start unit %s", name) - } - } - - return nil -} - // execCommand runs a command, streaming its output, and returns an error if it fails. func (c *systemdCasting) execCommand(ctx context.Context, name string, args ...string) error { cmd := exec.CommandContext(ctx, name, args...) @@ -619,8 +633,3 @@ func (c *systemdCasting) execCommand(ctx context.Context, name string, args ...s cmd.Stderr = os.Stderr return cmd.Run() } - -// systemctl runs a systemctl subcommand. -func (c *systemdCasting) systemctl(ctx context.Context, args ...string) error { - return c.execCommand(ctx, "systemctl", args...) -} diff --git a/internal/domain/event.go b/internal/domain/event.go index 28f06fcb..d1126473 100644 --- a/internal/domain/event.go +++ b/internal/domain/event.go @@ -20,10 +20,11 @@ var ( EventGauge = Event{name: "gauge"} EventForge = Event{name: "forge"} EventCast = Event{name: "cast"} + EventMelt = Event{name: "melt"} EventCatalog = Event{name: "catalog"} ) -var allEvents = []Event{EventGauge, EventForge, EventCast, EventCatalog} +var allEvents = []Event{EventGauge, EventForge, EventCast, EventMelt, EventCatalog} // NewEvent accepts only the names of declared base Event values. The returned // Event has no outcome; use Succeeded or Failed to attach one. diff --git a/internal/domain/ownership.go b/internal/domain/ownership.go index 976e78b6..bbda102c 100644 --- a/internal/domain/ownership.go +++ b/internal/domain/ownership.go @@ -1,58 +1,144 @@ package domain -import "strings" +import ( + "maps" + "slices" + "strings" +) -// Ownership captures which foundry Kinds own a group of platform workloads, -// derived from the foundry.signoz.io/kind label carried by each workload. -type Ownership struct { - kinds []string - unlabeled bool +// Owner is whoever a workload belongs to, as the attributes a platform records +// on it: labels on a container, tags on a cloud resource. Two workloads share +// an owner when every attribute they were asked for matches, so an owner is +// compared as a whole and never by one attribute at a time. +type Owner map[string]string + +// IsZero reports an owner that recorded nothing. A workload carrying none of +// the attributes asked for belongs to no one foundry can name, which is not +// the same as belonging to an owner whose every attribute is empty. +func (owner Owner) IsZero() bool { + for _, value := range owner { + if value != "" { + return false + } + } + + return true } -// ParseOwnership derives ownership from label values, one workload per line; -// an empty line is a workload without the label. -func ParseOwnership(labels string) Ownership { - lines := strings.Split(labels, "\n") +// Equal treats an absent attribute and an empty one as the same, so an owner +// asked for fewer attributes still compares against one asked for more. +func (owner Owner) Equal(other Owner) bool { + for key, value := range owner { + if other[key] != value { + return false + } + } - if n := len(lines); n > 0 && lines[n-1] == "" { - lines = lines[:n-1] + for key, value := range other { + if owner[key] != value { + return false + } } - ownership := Ownership{} - seen := map[string]bool{} + return true +} - for _, line := range lines { - kind := strings.TrimSpace(line) +// String renders the attributes in key order, so the same owner always reads +// the same way in a message. +func (owner Owner) String() string { + pairs := make([]string, 0, len(owner)) + for _, key := range slices.Sorted(maps.Keys(owner)) { + pairs = append(pairs, key+"="+owner[key]) + } - if kind == "" { - ownership.unlabeled = true + return strings.Join(pairs, ",") +} + +// ParseOwner reads an owner back from its String form, +// "kind=Installation,name=signoz". It is String's inverse. +func ParseOwner(raw string) Owner { + owner := Owner{} + if raw == "" { + return owner + } + + for pair := range strings.SplitSeq(raw, ",") { + key, value, _ := strings.Cut(pair, "=") + owner[key] = value + } + + return owner +} + +// Read pulls this owner's attributes out of what a platform recorded, keeping +// only the keys asked for: a workload also carries labels nobody asked about. +func (owner Owner) Read(recorded map[string]string) Owner { + read := Owner{} + for key := range owner { + read[key] = recorded[key] + } + + return read +} + +// Ownership is the owners a group of workloads reports, one owner per +// workload, deduplicated. +type Ownership struct { + owners []Owner + unowned bool +} + +// NewOwnership records what each workload reported. A workload that recorded +// nothing marks the group as partly unowned rather than becoming an owner in +// its own right. +func NewOwnership(owners ...Owner) Ownership { + ownership := Ownership{} + + for _, owner := range owners { + if owner.IsZero() { + ownership.unowned = true continue } - if seen[kind] { + if slices.ContainsFunc(ownership.owners, owner.Equal) { continue } - seen[kind] = true - ownership.kinds = append(ownership.kinds, kind) + ownership.owners = append(ownership.owners, owner) } return ownership } -// Foreign returns the owning Kind that is not self, when one exists. -func (ownership Ownership) Foreign(self string) (string, bool) { - for _, kind := range ownership.kinds { - if kind != self { - return kind, true +// ParseOwnership reads one owner per line, each in Owner.String form, into a +// deduplicated group. A line with no attributes marks the group partly unowned, +// the same as NewOwnership. +func ParseOwnership(output string) Ownership { + owners := []Owner{} + for line := range strings.SplitSeq(strings.TrimRight(output, "\n"), "\n") { + if line == "" { + continue + } + + owners = append(owners, ParseOwner(line)) + } + + return NewOwnership(owners...) +} + +// Foreign returns an owner that is not self, when one exists. +func (ownership Ownership) Foreign(self Owner) (Owner, bool) { + for _, owner := range ownership.owners { + if !owner.Equal(self) { + return owner, true } } - return "", false + return nil, false } -// HasUnlabeled reports workloads carrying no ownership label: either a -// pre-label foundry deployment or a foreign project sharing the name. -func (ownership Ownership) HasUnlabeled() bool { - return ownership.unlabeled +// HasUnowned reports workloads that recorded no owner: either a deployment +// made before foundry stamped them, or a foreign one sharing the same name. +func (ownership Ownership) HasUnowned() bool { + return ownership.unowned } diff --git a/internal/domain/ownership_test.go b/internal/domain/ownership_test.go index c1e08213..cfdea0cb 100644 --- a/internal/domain/ownership_test.go +++ b/internal/domain/ownership_test.go @@ -6,69 +6,227 @@ import ( "github.com/stretchr/testify/assert" ) -func TestParseOwnership(t *testing.T) { +func TestOwnerIsZero(t *testing.T) { tests := []struct { - name string - out string - self string - expectedForeign string - expectedConflict bool - expectedUnlabeled bool + name string + owner Owner + expectedZero bool + }{ + {name: "Nil_Zero", owner: nil, expectedZero: true}, + {name: "Empty_Zero", owner: Owner{}, expectedZero: true}, + {name: "AllValuesEmpty_Zero", owner: Owner{"kind": "", "name": ""}, expectedZero: true}, + {name: "OneValueSet_NotZero", owner: Owner{"kind": "", "name": "signoz"}, expectedZero: false}, + {name: "AllValuesSet_NotZero", owner: Owner{"kind": "Installation", "name": "signoz"}, expectedZero: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedZero, tt.owner.IsZero()) + }) + } +} + +func TestOwnerRead(t *testing.T) { + tests := []struct { + name string + owner Owner + recorded map[string]string + expectedOwner Owner }{ { - name: "Empty_NoOwnership", - out: "", - self: "CollectionAgent", + name: "AskedKeysOnly_Kept", + owner: Owner{"kind": "", "name": ""}, + recorded: map[string]string{"kind": "Installation", "name": "signoz", "owner": "helm"}, + expectedOwner: Owner{"kind": "Installation", "name": "signoz"}, }, { - name: "SelfKind_NoConflict", - out: "CollectionAgent\n", - self: "CollectionAgent", + name: "MissingKey_Empty", + owner: Owner{"kind": "", "name": ""}, + recorded: map[string]string{"kind": "Installation"}, + expectedOwner: Owner{"kind": "Installation", "name": ""}, }, { - name: "ForeignKind_Conflicts", - out: "Installation\n", - self: "CollectionAgent", - expectedForeign: "Installation", - expectedConflict: true, + name: "NothingAsked_Empty", + owner: Owner{}, + recorded: map[string]string{"kind": "Installation"}, + expectedOwner: Owner{}, }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedOwner, tt.owner.Read(tt.recorded)) + }) + } +} + +func TestOwnerEqual(t *testing.T) { + tests := []struct { + name string + owner Owner + other Owner + expectedEqual bool + }{ { - name: "UnlabeledOnly_UnlabeledWithoutConflict", - out: "\n\n", - self: "CollectionAgent", - expectedUnlabeled: true, + name: "SameAttributes_Equal", + owner: Owner{"kind": "Installation", "name": "signoz"}, + other: Owner{"kind": "Installation", "name": "signoz"}, + expectedEqual: true, }, { - name: "UnlabeledAndForeign_Conflicts", - out: "\nInstallation\n", - self: "CollectionAgent", - expectedForeign: "Installation", - expectedConflict: true, - expectedUnlabeled: true, + // One attribute of the set differing is a different owner: an + // owner is compared as a whole. + name: "OneAttributeDiffers_NotEqual", + owner: Owner{"kind": "Installation", "name": "signoz"}, + other: Owner{"kind": "CollectionAgent", "name": "signoz"}, + expectedEqual: false, }, { - name: "DuplicateForeign_SingleForeign", - out: "Installation\nInstallation\n", - self: "CollectionAgent", - expectedForeign: "Installation", - expectedConflict: true, + name: "AbsentMatchesEmpty_Equal", + owner: Owner{"kind": "Installation", "name": ""}, + other: Owner{"kind": "Installation"}, + expectedEqual: true, }, { - name: "SelfAmongUnlabeled_NoConflict", - out: "CollectionAgent\n\nCollectionAgent\n", - self: "CollectionAgent", - expectedUnlabeled: true, + name: "ExtraAttribute_NotEqual", + owner: Owner{"kind": "Installation"}, + other: Owner{"kind": "Installation", "name": "signoz"}, + expectedEqual: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ownership := ParseOwnership(tt.out) + assert.Equal(t, tt.expectedEqual, tt.owner.Equal(tt.other)) + assert.Equal(t, tt.expectedEqual, tt.other.Equal(tt.owner)) + }) + } +} + +func TestOwnerString(t *testing.T) { + owner := Owner{"name": "signoz", "kind": "Installation", "managed-by": "foundry"} + + assert.Equal(t, "kind=Installation,managed-by=foundry,name=signoz", owner.String()) + assert.Empty(t, Owner{}.String()) +} + +func TestOwnership(t *testing.T) { + installation := Owner{"kind": "Installation", "name": "signoz"} + agent := Owner{"kind": "CollectionAgent", "name": "signoz"} + + tests := []struct { + name string + owners []Owner + self Owner + expectedForeign Owner + expectedUnowned bool + }{ + { + name: "NoWorkloads_NothingOwned", + owners: nil, + self: installation, + }, + { + name: "OnlySelf_NoConflict", + owners: []Owner{installation, installation}, + self: installation, + }, + { + name: "OtherOwner_Conflict", + owners: []Owner{agent}, + self: installation, + expectedForeign: agent, + }, + { + name: "SelfBesideOther_Conflict", + owners: []Owner{installation, agent}, + self: installation, + expectedForeign: agent, + }, + { + name: "NothingRecorded_Unowned", + owners: []Owner{{"kind": "", "name": ""}}, + self: installation, + expectedUnowned: true, + }, + { + name: "SelfBesideUnrecorded_UnownedNoConflict", + owners: []Owner{installation, {"kind": "", "name": ""}}, + self: installation, + expectedUnowned: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ownership := NewOwnership(tt.owners...) foreign, conflict := ownership.Foreign(tt.self) + assert.Equal(t, tt.expectedForeign != nil, conflict) assert.Equal(t, tt.expectedForeign, foreign) - assert.Equal(t, tt.expectedConflict, conflict) - assert.Equal(t, tt.expectedUnlabeled, ownership.HasUnlabeled()) + assert.Equal(t, tt.expectedUnowned, ownership.HasUnowned()) + }) + } +} + +// The same owner reported by many workloads is one owner. +func TestOwnershipDeduplicates(t *testing.T) { + installation := Owner{"kind": "Installation", "name": "signoz"} + + ownership := NewOwnership(installation, installation, installation) + + assert.Len(t, ownership.owners, 1) +} + +// ParseOwner is String's inverse, so an owner survives a round trip through its +// own string form. +func TestParseOwner(t *testing.T) { + tests := []struct { + name string + owner Owner + }{ + {name: "Attributes_RoundTrip", owner: Owner{"kind": "Installation", "name": "signoz"}}, + {name: "EmptyValues_RoundTrip", owner: Owner{"kind": "", "name": ""}}, + {name: "None_RoundTrip", owner: Owner{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.owner, ParseOwner(tt.owner.String())) }) } } + +// ParseOwnership reads one owner per line, each in Owner.String form, so a line +// with no attributes marks the group partly unowned rather than becoming an +// owner whose every value is empty. +func TestParseOwnership(t *testing.T) { + t.Run("Rows_NoConflict", func(t *testing.T) { + ownership := ParseOwnership("kind=Installation,name=signoz\nkind=Installation,name=signoz\n") + + _, conflict := ownership.Foreign(Owner{"kind": "Installation", "name": "signoz"}) + + assert.False(t, conflict) + assert.False(t, ownership.HasUnowned()) + }) + + t.Run("ForeignRow_Conflict", func(t *testing.T) { + ownership := ParseOwnership("kind=Installation,name=other\n") + + _, conflict := ownership.Foreign(Owner{"kind": "Installation", "name": "signoz"}) + + assert.True(t, conflict) + }) + + t.Run("EmptyValues_Unowned", func(t *testing.T) { + ownership := ParseOwnership("kind=,name=\n") + + assert.True(t, ownership.HasUnowned()) + }) + + t.Run("NoOutput_Empty", func(t *testing.T) { + ownership := ParseOwnership("") + + assert.False(t, ownership.HasUnowned()) + }) +} diff --git a/internal/domain/release.go b/internal/domain/release.go new file mode 100644 index 00000000..e52d89b6 --- /dev/null +++ b/internal/domain/release.go @@ -0,0 +1,24 @@ +package domain + +import "github.com/signoz/foundry/internal/errors" + +// Release is foundry's reading of one deployment: constructed, never stored. +type Release struct { + // Name is the unit's identity (metadata.name). + Name string + + // Owner is what the unit asserts on the host. + Owner Owner +} + +func (r Release) Validate() error { + if r.Name == "" { + return errors.Newf(errors.TypeInvalidInput, "failed to validate release: no name is stated") + } + + if len(r.Owner) == 0 { + return errors.Newf(errors.TypeInvalidInput, "failed to validate release: no owner is stated") + } + + return nil +} diff --git a/internal/errors/error.go b/internal/errors/error.go index d70eeb6d..63b287b0 100644 --- a/internal/errors/error.go +++ b/internal/errors/error.go @@ -50,7 +50,7 @@ func Newf(t typ, info string, args ...any) *base { } } -func Wrapf(cause error, t typ, format string, args ...any) error { +func Wrapf(cause error, t typ, format string, args ...any) *base { return &base{ t: t, info: fmt.Sprintf(format, args...), diff --git a/internal/errors/exception.go b/internal/errors/exception.go index bccf1a71..f61bf1ce 100644 --- a/internal/errors/exception.go +++ b/internal/errors/exception.go @@ -65,6 +65,7 @@ func exceptionAttrs(e *Exception) []slog.Attr { } attrs = append(attrs, slog.String("message", e.Message)) + if e.Cause != nil { attrs = append(attrs, slog.GroupAttrs("cause", exceptionAttrs(e.Cause)...)) } diff --git a/internal/errors/tail.go b/internal/errors/tail.go new file mode 100644 index 00000000..3352c155 --- /dev/null +++ b/internal/errors/tail.go @@ -0,0 +1,35 @@ +package errors + +// tailCap bounds what one tail keeps: enough to hold a tool's closing +// diagnostic, small enough to ride inside an error and a log line. +const tailCap = 8 << 10 + +// Tail keeps the last tailCap bytes written to it: a diagnostic, not a +// transcript. When both of a tool's streams write here, their order is +// whatever the writes were. +// +// Inspired by os/exec's prefixSuffixSaver, which bounds what +// ExitError.Stderr retains for the same reason. +type Tail struct { + buf []byte +} + +func (t *Tail) Write(p []byte) (int, error) { + if len(p) >= tailCap { + t.buf = append(t.buf[:0], p[len(p)-tailCap:]...) + + return len(p), nil + } + + if over := len(t.buf) + len(p) - tailCap; over > 0 { + t.buf = append(t.buf[:0], t.buf[over:]...) + } + + t.buf = append(t.buf, p...) + + return len(p), nil +} + +func (t *Tail) String() string { + return string(t.buf) +} diff --git a/internal/foundry/cast.go b/internal/foundry/cast.go index aad3fb42..601e91d0 100644 --- a/internal/foundry/cast.go +++ b/internal/foundry/cast.go @@ -5,6 +5,7 @@ import ( "log/slog" "github.com/signoz/foundry/api/v1alpha1" + foundryerrors "github.com/signoz/foundry/internal/errors" ) func (foundry *Foundry) Cast(ctx context.Context, machinery v1alpha1.Machinery, poursPath string) error { @@ -17,5 +18,9 @@ func (foundry *Foundry) Cast(ctx context.Context, machinery v1alpha1.Machinery, slog.String("casting.kind", machinery.Kind().String()), slog.String("casting.metadata.name", machinery.Name())) + if ctx.Err() != nil { + return foundryerrors.Wrapf(ctx.Err(), foundryerrors.TypeInternal, "failed to cast %s: the run was interrupted", machinery.Name()) + } + return p.Cast(ctx, poursPath) } diff --git a/internal/foundry/gauge.go b/internal/foundry/gauge.go index cb75a710..0cd13e7e 100644 --- a/internal/foundry/gauge.go +++ b/internal/foundry/gauge.go @@ -7,51 +7,47 @@ import ( "github.com/signoz/foundry/api/v1alpha1" foundryerrors "github.com/signoz/foundry/internal/errors" - "github.com/signoz/foundry/internal/tooler" ) -// Gauge checks the tools the whole casting file needs. Documents that share a -// tool gauge it once, so a machine is neither probed nor reported twice. +// Gauge proves a tool once across documents: proving one is a statement about +// the machine, not about a document. Every tool is gauged before any of them +// reports, so a machine missing several is told about all of them at once. func (foundry *Foundry) Gauge(ctx context.Context, machineries []v1alpha1.Machinery) error { - toolers := []tooler.Tooler{} + proven := map[string]struct{}{} + unavailable := []string{} + for _, machinery := range machineries { p, err := foundry.Plan(ctx, machinery) if err != nil { return err } - toolers = append(toolers, p.Toolers()...) - } + foundry.Logger.InfoContext(ctx, "gauging", + slog.String("casting.kind", machinery.Kind().String()), + slog.String("casting.metadata.name", machinery.Name())) - unavailableTools := []string{} - for _, tooler := range dedupeByName(toolers) { - if err := tooler.Gauge(ctx); err != nil { - foundry.Logger.ErrorContext(ctx, "tool is not available or cannot be detected properly", slog.String("tool.name", tooler.Name()), foundryerrors.LogAttr(err)) - unavailableTools = append(unavailableTools, tooler.Name()) - continue - } - foundry.Logger.InfoContext(ctx, "tool is available", slog.String("tool.name", tooler.Name())) - } - if len(unavailableTools) > 0 { - return foundryerrors.Newf(foundryerrors.TypeNotFound, "tools are not available, please install them and try again: %s", strings.Join(unavailableTools, ", ")) - } - return nil -} + for _, tool := range p.Toolers() { + if _, done := proven[tool.Name()]; done { + continue + } + + proven[tool.Name()] = struct{}{} + + if err := tool.Gauge(ctx); err != nil { + foundry.Logger.ErrorContext(ctx, "tool is not available or cannot be detected properly", + slog.String("tool.name", tool.Name()), foundryerrors.LogAttr(err)) + unavailable = append(unavailable, tool.Name()) -// dedupeByName keeps the first tooler of each name, in the order the documents -// asked for them. -func dedupeByName(toolers []tooler.Tooler) []tooler.Tooler { - deduped := make([]tooler.Tooler, 0, len(toolers)) - named := make(map[string]struct{}, len(toolers)) + continue + } - for _, tooler := range toolers { - if _, gathered := named[tooler.Name()]; gathered { - continue + foundry.Logger.InfoContext(ctx, "tool is available", slog.String("tool.name", tool.Name())) } + } - named[tooler.Name()] = struct{}{} - deduped = append(deduped, tooler) + if len(unavailable) > 0 { + return foundryerrors.Newf(foundryerrors.TypeNotFound, "tools are not available, please install them and try again: %s", strings.Join(unavailable, ", ")) } - return deduped + return nil } diff --git a/internal/foundry/melt.go b/internal/foundry/melt.go new file mode 100644 index 00000000..f1f9c32a --- /dev/null +++ b/internal/foundry/melt.go @@ -0,0 +1,28 @@ +package foundry + +import ( + "context" + "log/slog" + + "github.com/signoz/foundry/api/v1alpha1" + foundryerrors "github.com/signoz/foundry/internal/errors" +) + +// Melt melts one document. The caller walks the set backwards, against the +// order the lock records, so a workload leaves before the substrate it runs on. +func (foundry *Foundry) Melt(ctx context.Context, machinery v1alpha1.Machinery, poursPath string) error { + p, err := foundry.Plan(ctx, machinery) + if err != nil { + return err + } + + if ctx.Err() != nil { + return foundryerrors.Wrapf(ctx.Err(), foundryerrors.TypeInternal, "failed to melt %s: the run was interrupted", machinery.Name()) + } + + foundry.Logger.InfoContext(ctx, "melting", + slog.String("casting.kind", machinery.Kind().String()), + slog.String("casting.metadata.name", machinery.Name())) + + return p.Melt(ctx, poursPath) +} diff --git a/internal/planner/planner.go b/internal/planner/planner.go index 5791ca91..92758146 100644 --- a/internal/planner/planner.go +++ b/internal/planner/planner.go @@ -14,7 +14,7 @@ import ( // - identity: Machinery, Patches, Toolers // - ordering: MoldingKinds (the moldings this Kind processes, in order) // - stages: EnrichStatus, Mold, MergeStatusIntoSpec -// - lifecycle: Forge, Cast +// - lifecycle: Forge, Cast, Melt type Planner interface { Machinery() v1alpha1.Machinery Patches() []v1alpha1.PatchEntry @@ -27,4 +27,5 @@ type Planner interface { Forge(ctx context.Context, target string) ([]domain.Material, error) Cast(ctx context.Context, poursPath string) error + Melt(ctx context.Context, poursPath string) error } diff --git a/internal/tooler/approval.go b/internal/tooler/approval.go new file mode 100644 index 00000000..b7c5dc80 --- /dev/null +++ b/internal/tooler/approval.go @@ -0,0 +1,16 @@ +package tooler + +import "context" + +type approvalKey struct{} + +func WithApproval(ctx context.Context) context.Context { + return context.WithValue(ctx, approvalKey{}, true) +} + +// Approved fails closed: a missing stamp refuses, never an unapproved run. +func Approved(ctx context.Context) bool { + yes, _ := ctx.Value(approvalKey{}).(bool) + + return yes +} diff --git a/internal/tooler/approval_test.go b/internal/tooler/approval_test.go new file mode 100644 index 00000000..be6dc707 --- /dev/null +++ b/internal/tooler/approval_test.go @@ -0,0 +1,26 @@ +package tooler + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestApproved(t *testing.T) { + tests := []struct { + name string + ctx context.Context + pass bool + }{ + {name: "Stamped_Valid", ctx: WithApproval(context.Background()), pass: true}, + {name: "Bare_Invalid", ctx: context.Background()}, + {name: "OtherValue_Invalid", ctx: context.WithValue(context.Background(), approvalKey{}, "yes")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.pass, Approved(tt.ctx)) + }) + } +} diff --git a/internal/tooler/binarytooler/tooler.go b/internal/tooler/binarytooler/tooler.go deleted file mode 100644 index b3bbc442..00000000 --- a/internal/tooler/binarytooler/tooler.go +++ /dev/null @@ -1,39 +0,0 @@ -package binarytooler - -import ( - "context" - "os" - - "github.com/signoz/foundry/internal/errors" - root "github.com/signoz/foundry/internal/tooler" -) - -var _ root.Tooler = (*binaryTooler)(nil) - -// binaryTooler checks that a named binary exists at a resolved path. The path is -// supplied at construction (resolved from casting annotations with a fallback by -// the caller), so the tooler itself stays config-blind: it only verifies that -// the binary the deployment will exec is actually present. -type binaryTooler struct { - name string - path string -} - -func New(name, path string) *binaryTooler { - return &binaryTooler{name: name, path: path} -} - -func (tooler *binaryTooler) Name() string { - return tooler.name -} - -func (tooler *binaryTooler) Gauge(ctx context.Context) error { - if _, err := os.Stat(tooler.path); err != nil { - return errors.Newf(errors.TypeNotFound, "%s binary not found at %q", tooler.name, tooler.path) - } - return nil -} - -func (tooler *binaryTooler) Install(ctx context.Context) error { - return nil -} diff --git a/internal/tooler/connection.go b/internal/tooler/connection.go new file mode 100644 index 00000000..7ff07000 --- /dev/null +++ b/internal/tooler/connection.go @@ -0,0 +1,50 @@ +package tooler + +import ( + "golang.org/x/oauth2" + + "github.com/signoz/foundry/internal/domain" + foundryerrors "github.com/signoz/foundry/internal/errors" +) + +// Connection's TokenSource is behavior rather than data: it never serializes, +// and no secret outlives the call. +type Connection struct { + address domain.Address + + ca []byte + + tokens oauth2.TokenSource +} + +func NewConnection(address domain.Address, ca []byte, tokens oauth2.TokenSource) (Connection, error) { + if address.Host() == "" { + return Connection{}, foundryerrors.Newf(foundryerrors.TypeInvalidInput, "failed to create connection: no address is stated") + } + + if len(ca) == 0 { + return Connection{}, foundryerrors.Newf(foundryerrors.TypeInvalidInput, "failed to create connection: no certificate authority is stated") + } + + if tokens == nil { + return Connection{}, foundryerrors.Newf(foundryerrors.TypeInvalidInput, "failed to create connection: no token source is stated") + } + + return Connection{address: address, ca: ca, tokens: tokens}, nil +} + +func (c Connection) IsZero() bool { + return c.address.Host() == "" +} + +func (c Connection) Address() domain.Address { + return c.address +} + +func (c Connection) CA() []byte { + return c.ca +} + +func (c Connection) TokenSource() oauth2.TokenSource { + return c.tokens +} diff --git a/internal/tooler/connection_test.go b/internal/tooler/connection_test.go new file mode 100644 index 00000000..d3febad6 --- /dev/null +++ b/internal/tooler/connection_test.go @@ -0,0 +1,52 @@ +package tooler + +import ( + "testing" + + "golang.org/x/oauth2" + + "github.com/signoz/foundry/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewConnection(t *testing.T) { + address := domain.MustNewAddress("https", "cluster.example", 0) + tokens := oauth2.StaticTokenSource(&oauth2.Token{}) + + tests := []struct { + name string + address domain.Address + ca []byte + tokens oauth2.TokenSource + pass bool + }{ + {name: "Whole_Valid", address: address, ca: []byte("ca"), tokens: tokens, pass: true}, + {name: "UnstatedAddress_Invalid", ca: []byte("ca"), tokens: tokens}, + {name: "UnstatedCertificateAuthority_Invalid", address: address, tokens: tokens}, + {name: "UnstatedTokenSource_Invalid", address: address, ca: []byte("ca")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + connection, err := NewConnection(tt.address, tt.ca, tt.tokens) + + if !tt.pass { + assert.Error(t, err) + assert.True(t, connection.IsZero()) + + return + } + + require.NoError(t, err) + assert.False(t, connection.IsZero()) + assert.Equal(t, "https://cluster.example", connection.Address().String()) + assert.Equal(t, []byte("ca"), connection.CA()) + assert.NotNil(t, connection.TokenSource()) + }) + } +} + +func TestConnectionZeroIsAmbient(t *testing.T) { + assert.True(t, Connection{}.IsZero()) +} diff --git a/internal/tooler/dockercomposetooler/tooler.go b/internal/tooler/dockercomposetooler/tooler.go index 685d638b..025af89e 100644 --- a/internal/tooler/dockercomposetooler/tooler.go +++ b/internal/tooler/dockercomposetooler/tooler.go @@ -1,40 +1,167 @@ +// Package dockercomposetooler speaks docker compose. package dockercomposetooler import ( "context" - "os/exec" + "log/slog" + "maps" + "slices" + "strings" - "github.com/signoz/foundry/internal/errors" - root "github.com/signoz/foundry/internal/tooler" + "github.com/signoz/foundry/internal/domain" + foundryerrors "github.com/signoz/foundry/internal/errors" + "github.com/signoz/foundry/internal/tooler" ) -var _ root.Tooler = (*dockerComposeTooler)(nil) +var _ tooler.Tooler = (*Tooler)(nil) -type dockerComposeTooler struct{} +// Release is the deployable unit the casting drafts for a compose project; the +// project compose keys on is the release name. +type Release struct { + domain.Release -func New() *dockerComposeTooler { - return &dockerComposeTooler{} + File string } -func (tooler *dockerComposeTooler) Name() string { - return "docker-compose" -} +func (r Release) Validate() error { + if err := r.Release.Validate(); err != nil { + return err + } -func (tooler *dockerComposeTooler) Gauge(ctx context.Context) error { - // Legacy standalone binary. - if err := root.ExecChecker(ctx, "docker-compose"); err == nil { - return nil + if r.File == "" { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "failed to validate release: no compose file is stated") } - if err := root.ExecChecker(ctx, "docker"); err == nil { - if err := exec.CommandContext(ctx, "docker", "compose", "version").Run(); err == nil { - return nil + return nil +} + +type Tooler struct { + tooler.Tool + + // words is the resolved command prefix, memoized by command. + words []string +} + +func New(logger *slog.Logger) *Tooler { + return &Tooler{Tool: tooler.NewTool("docker compose", logger)} +} + +func Lookup(toolers []tooler.Tooler) (*Tooler, error) { + for _, t := range toolers { + if compose, ok := t.(*Tooler); ok { + return compose, nil } } - return errors.Newf(errors.TypeNotFound, "neither 'docker-compose' nor the 'docker compose' plugin is available") + return nil, foundryerrors.Newf(foundryerrors.TypeNotFound, "failed to look up the compose tooler: it is not registered for this casting") } -func (tooler *dockerComposeTooler) Install(ctx context.Context) error { - return nil +func (t *Tooler) Gauge(ctx context.Context) error { + _, err := t.command(ctx) + + return err +} + +// Up returns once the containers are started, not once they are healthy. +func (t *Tooler) Up(ctx context.Context, release Release) error { + return t.run(ctx, release, "up", "-d") +} + +// Down removes containers and networks; volumes stay (the data line). +func (t *Tooler) Down(ctx context.Context, release Release) error { + return t.run(ctx, release, "down") +} + +func (t *Tooler) Owners(ctx context.Context, release Release) (domain.Ownership, error) { + if err := release.Release.Validate(); err != nil { + return domain.Ownership{}, err + } + + return t.read(ctx, release) +} + +func (t *Tooler) run(ctx context.Context, release Release, verb string, args ...string) error { + if err := release.Validate(); err != nil { + return err + } + + words, err := t.command(ctx) + if err != nil { + return err + } + + if err := tooler.Verify(ctx, t.Tool, release.Release, func(ctx context.Context) (domain.Ownership, error) { + return t.read(ctx, release) + }); err != nil { + return err + } + + argv := append(slices.Clone(words), "-f", release.File, verb) + argv = append(argv, args...) + + inv := tooler.Invocation{Argv: argv, Mode: tooler.Stream} + + t.Logger.DebugContext(ctx, "running command", slog.String("command", inv.Command())) + + _, err = tooler.Invoke(ctx, t.Settings, inv) + + return err +} + +func (t *Tooler) read(ctx context.Context, release Release) (domain.Ownership, error) { + docker, err := tooler.Resolve("docker") + if err != nil { + return domain.Ownership{}, foundryerrors.Newf(foundryerrors.TypeNotFound, "failed to run docker ps: docker is not available") + } + + keys := slices.Sorted(maps.Keys(release.Owner)) + + // Each container prints its owner in domain.Owner's String form, sorted, so + // domain.ParseOwnership reads it straight back. + directives := make([]string, 0, len(keys)) + for _, key := range keys { + directives = append(directives, key+`={{.Label "`+key+`"}}`) + } + + result, err := tooler.Invoke(ctx, t.Settings, tooler.Invocation{ + Argv: []string{docker, "ps", "-a", + "--filter", "label=com.docker.compose.project=" + release.Name, + "--format", strings.Join(directives, ",")}, + Mode: tooler.Capture, + }) + if err != nil { + return domain.Ownership{}, err + } + + return domain.ParseOwnership(string(result.Output)), nil +} + +// The memo is unguarded; foundry runs single-threaded, and a lock would claim a +// concurrency contract toolers do not have. +func (t *Tooler) command(ctx context.Context) ([]string, error) { + if len(t.words) != 0 { + return t.words, nil + } + + path, _ := tooler.Resolve("docker") + + if path != "" { + _, err := tooler.Invoke(ctx, t.Settings, tooler.Invocation{ + Argv: []string{path, "compose", "version"}, + Mode: tooler.Capture, + }) + if err == nil { + t.words = []string{path, "compose"} + + return t.words, nil + } + } + + if legacy, err := tooler.Resolve("docker-compose"); err == nil { + t.words = []string{legacy} + + return t.words, nil + } + + return nil, foundryerrors.Newf(foundryerrors.TypeNotFound, "failed to find docker compose: install the docker compose plugin or docker-compose") } diff --git a/internal/tooler/dockercomposetooler/tooler_test.go b/internal/tooler/dockercomposetooler/tooler_test.go new file mode 100644 index 00000000..10c62361 --- /dev/null +++ b/internal/tooler/dockercomposetooler/tooler_test.go @@ -0,0 +1,104 @@ +package dockercomposetooler + +import ( + "context" + "io" + "log/slog" + "maps" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/signoz/foundry/internal/domain" + "github.com/signoz/foundry/internal/tooler" + "github.com/stretchr/testify/assert" +) + +func requireEngine(t *testing.T) { + t.Helper() + + if testing.Short() { + t.Skip("skipping docker engine test in short mode") + } + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker is not available") + } + + if err := exec.Command("docker", "info").Run(); err != nil { + t.Skip("docker engine is not running") + } +} + +func TestUpDown(t *testing.T) { + requireEngine(t) + + composeFile := filepath.Join(t.TempDir(), "compose.yaml") + contents := "name: dockercomposetooler-test\nservices:\n ok:\n image: busybox:stable\n command: [\"sleep\", \"300\"]\n" + assert.NoError(t, os.WriteFile(composeFile, []byte(contents), 0o644)) + + r := New(slog.New(slog.DiscardHandler)) + r.Settings = tooler.NewSettings(io.Discard) + + release := Release{ + Release: domain.Release{ + Name: "dockercomposetooler-test", + Owner: domain.Owner{ + "foundry.signoz.io/managed-by": "foundry", + "foundry.signoz.io/kind": "Installation", + "foundry.signoz.io/name": "dockercomposetooler-test", + }, + }, + File: composeFile, + } + + assert.NoError(t, r.Gauge(context.Background())) + assert.NoError(t, r.Up(context.Background(), release)) + assert.NoError(t, r.Down(context.Background(), release)) +} + +func TestOwnerGuardsTheProject(t *testing.T) { + requireEngine(t) + + const project = "dockercomposetooler-owner-test" + + owner := domain.Owner{ + "foundry.signoz.io/managed-by": "foundry", + "foundry.signoz.io/kind": "Installation", + "foundry.signoz.io/name": project, + } + + labels := strings.Builder{} + for key, value := range owner { + labels.WriteString(" " + key + ": " + value + "\n") + } + + composeFile := filepath.Join(t.TempDir(), "compose.yaml") + contents := "name: " + project + "\nservices:\n ok:\n image: busybox:stable\n command: [\"sleep\", \"300\"]\n labels:\n" + labels.String() + assert.NoError(t, os.WriteFile(composeFile, []byte(contents), 0o644)) + + r := New(slog.New(slog.DiscardHandler)) + r.Settings = tooler.NewSettings(io.Discard) + + release := Release{ + Release: domain.Release{Name: project}, + File: composeFile, + } + + installation := release + installation.Owner = owner + + // One label of the set differing is a different owner. + agent := release + agent.Owner = maps.Clone(owner) + agent.Owner["foundry.signoz.io/kind"] = "CollectionAgent" + + assert.NoError(t, r.Up(context.Background(), installation)) + t.Cleanup(func() { _ = r.Down(context.Background(), installation) }) + + assert.ErrorContains(t, r.Up(context.Background(), agent), "already belongs to") + assert.ErrorContains(t, r.Down(context.Background(), agent), "already belongs to") + assert.NoError(t, r.Down(context.Background(), installation)) +} diff --git a/internal/tooler/dockerswarmtooler/tooler.go b/internal/tooler/dockerswarmtooler/tooler.go index 12f6af5f..f8fc8249 100644 --- a/internal/tooler/dockerswarmtooler/tooler.go +++ b/internal/tooler/dockerswarmtooler/tooler.go @@ -1,57 +1,162 @@ +// Package dockerswarmtooler speaks docker stack. package dockerswarmtooler import ( "context" - "errors" - "os/exec" + "log/slog" + "maps" + "slices" "strings" - root "github.com/signoz/foundry/internal/tooler" + "github.com/signoz/foundry/internal/domain" + foundryerrors "github.com/signoz/foundry/internal/errors" + "github.com/signoz/foundry/internal/tooler" ) -var _ root.Tooler = (*dockerSwarmTooler)(nil) +var _ tooler.Tooler = (*Tooler)(nil) -type dockerSwarmTooler struct{} +// Release is the deployable unit the casting drafts for a swarm deploy; the +// stack compose keys on is the release name. +type Release struct { + domain.Release -func New() *dockerSwarmTooler { - return &dockerSwarmTooler{} + File string } -func (tooler *dockerSwarmTooler) Name() string { - return "docker-swarm" +func (r Release) Validate() error { + if err := r.Release.Validate(); err != nil { + return err + } + + if r.File == "" { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "failed to validate release: no compose file is stated") + } + + return nil +} + +type Tooler struct { + tooler.Tool + + // words is the resolved command prefix, memoized by command. + words []string +} + +func New(logger *slog.Logger) *Tooler { + return &Tooler{Tool: tooler.NewTool("docker stack", logger)} +} + +func Lookup(toolers []tooler.Tooler) (*Tooler, error) { + for _, t := range toolers { + if swarm, ok := t.(*Tooler); ok { + return swarm, nil + } + } + + return nil, foundryerrors.Newf(foundryerrors.TypeNotFound, "failed to look up the swarm tooler: it is not registered for this casting") +} + +func (t *Tooler) Gauge(ctx context.Context) error { + _, err := t.command(ctx) + + return err +} + +// Up returns once the stack is accepted, not once its services converge. +func (t *Tooler) Up(ctx context.Context, release Release) error { + return t.run(ctx, release, "deploy", "-d", "-c", release.File) +} + +// Down removes the stack's services and networks; volumes stay (the data line). +func (t *Tooler) Down(ctx context.Context, release Release) error { + return t.run(ctx, release, "rm") +} + +func (t *Tooler) Owners(ctx context.Context, release Release) (domain.Ownership, error) { + if err := release.Release.Validate(); err != nil { + return domain.Ownership{}, err + } + + return t.read(ctx, release) } -// Gauge checks that docker is available, swarm mode is active, and the local -// node is a manager (required for docker stack deploy). -func (tooler *dockerSwarmTooler) Gauge(ctx context.Context) error { - if err := root.ExecChecker(ctx, "docker"); err != nil { +func (t *Tooler) run(ctx context.Context, release Release, verb string, args ...string) error { + if err := release.Validate(); err != nil { return err } - // Check swarm is active. - stateCmd := exec.CommandContext(ctx, "docker", "info", "--format", "{{.Swarm.LocalNodeState}}") - stateOut, err := stateCmd.Output() + words, err := t.command(ctx) if err != nil { - return errors.New("failed to check docker swarm status: " + err.Error()) + return err } - state := strings.TrimSpace(string(stateOut)) - if state != "active" { - return errors.New("docker swarm is not active (state: " + state + "); run 'docker swarm init' to initialize") + + if err := tooler.Verify(ctx, t.Tool, release.Release, func(ctx context.Context) (domain.Ownership, error) { + return t.read(ctx, release) + }); err != nil { + return err } - // Verify the local node is a manager — stack deploy only works on managers. - roleCmd := exec.CommandContext(ctx, "docker", "info", "--format", "{{.Swarm.ControlAvailable}}") - roleOut, err := roleCmd.Output() + argv := append(slices.Clone(words), verb) + argv = append(argv, args...) + argv = append(argv, release.Name) + + inv := tooler.Invocation{Argv: argv, Mode: tooler.Stream} + + t.Logger.DebugContext(ctx, "running command", slog.String("command", inv.Command())) + + _, err = tooler.Invoke(ctx, t.Settings, inv) + + return err +} + +func (t *Tooler) read(ctx context.Context, release Release) (domain.Ownership, error) { + docker, err := tooler.Resolve("docker") if err != nil { - return errors.New("failed to check docker swarm manager status: " + err.Error()) + return domain.Ownership{}, foundryerrors.Newf(foundryerrors.TypeNotFound, "failed to run docker ps: docker is not available") } - if strings.TrimSpace(string(roleOut)) != "true" { - return errors.New("current node is a swarm worker, not a manager; 'docker stack deploy' must be run from a manager node") + + keys := slices.Sorted(maps.Keys(release.Owner)) + + // Each container prints its owner in domain.Owner's String form, sorted, so + // domain.ParseOwnership reads it straight back. + directives := make([]string, 0, len(keys)) + for _, key := range keys { + directives = append(directives, key+`={{.Label "`+key+`"}}`) } - return nil + result, err := tooler.Invoke(ctx, t.Settings, tooler.Invocation{ + Argv: []string{docker, "ps", "-a", + "--filter", "label=com.docker.stack.namespace=" + release.Name, + "--format", strings.Join(directives, ",")}, + Mode: tooler.Capture, + }) + if err != nil { + return domain.Ownership{}, err + } + + return domain.ParseOwnership(string(result.Output)), nil } -func (tooler *dockerSwarmTooler) Install(ctx context.Context) error { - return nil +// The memo is unguarded; foundry runs single-threaded, and a lock would claim a +// concurrency contract toolers do not have. +func (t *Tooler) command(ctx context.Context) ([]string, error) { + if len(t.words) != 0 { + return t.words, nil + } + + path, err := tooler.Resolve("docker") + if err != nil { + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeNotFound, "failed to find docker: install it from https://docs.docker.com/engine/install/") + } + + if _, err := tooler.Invoke(ctx, t.Settings, tooler.Invocation{ + Argv: []string{path, "--version"}, + Mode: tooler.Capture, + }); err != nil { + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeNotFound, "failed to find docker: install it from https://docs.docker.com/engine/install/") + } + + t.words = []string{path, "stack"} + + return t.words, nil } diff --git a/internal/tooler/dockerswarmtooler/tooler_test.go b/internal/tooler/dockerswarmtooler/tooler_test.go new file mode 100644 index 00000000..3d342aff --- /dev/null +++ b/internal/tooler/dockerswarmtooler/tooler_test.go @@ -0,0 +1,123 @@ +package dockerswarmtooler + +import ( + "context" + "io" + "log/slog" + "maps" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/signoz/foundry/internal/domain" + "github.com/signoz/foundry/internal/tooler" + "github.com/stretchr/testify/assert" +) + +func requireSwarm(t *testing.T) { + t.Helper() + + if testing.Short() { + t.Skip("skipping docker swarm test in short mode") + } + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker is not available") + } + + out, err := exec.Command("docker", "info", "--format", "{{.Swarm.ControlAvailable}}").Output() + if err != nil || strings.TrimSpace(string(out)) != "true" { + t.Skip("docker swarm manager is not available") + } +} + +func waitForStack(t *testing.T, stack string) { + t.Helper() + + for range 60 { + out, _ := exec.Command("docker", "ps", "-a", "--filter", "label=com.docker.stack.namespace="+stack, "--format", "{{.ID}}").Output() + if strings.TrimSpace(string(out)) != "" { + return + } + + time.Sleep(time.Second) + } + + t.Fatal("stack container did not appear") +} + +func TestUpDown(t *testing.T) { + requireSwarm(t) + + composeFile := filepath.Join(t.TempDir(), "compose.yaml") + contents := "version: \"3\"\nservices:\n ok:\n image: busybox:stable\n command: [\"sleep\", \"300\"]\n" + assert.NoError(t, os.WriteFile(composeFile, []byte(contents), 0o644)) + + r := New(slog.New(slog.DiscardHandler)) + r.Settings = tooler.NewSettings(io.Discard) + + release := Release{ + Release: domain.Release{ + Name: "dockerswarmtooler-test", + Owner: domain.Owner{ + "foundry.signoz.io/managed-by": "foundry", + "foundry.signoz.io/kind": "Installation", + "foundry.signoz.io/name": "dockerswarmtooler-test", + }, + }, + File: composeFile, + } + + assert.NoError(t, r.Gauge(context.Background())) + assert.NoError(t, r.Up(context.Background(), release)) + t.Cleanup(func() { _ = r.Down(context.Background(), release) }) + assert.NoError(t, r.Down(context.Background(), release)) +} + +func TestOwnerGuardsTheStack(t *testing.T) { + requireSwarm(t) + + const stack = "dockerswarmtooler-owner-test" + + owner := domain.Owner{ + "foundry.signoz.io/managed-by": "foundry", + "foundry.signoz.io/kind": "Installation", + "foundry.signoz.io/name": stack, + } + + labels := strings.Builder{} + for key, value := range owner { + labels.WriteString(" " + key + ": " + value + "\n") + } + + composeFile := filepath.Join(t.TempDir(), "compose.yaml") + contents := "version: \"3\"\nservices:\n ok:\n image: busybox:stable\n command: [\"sleep\", \"300\"]\n labels:\n" + labels.String() + assert.NoError(t, os.WriteFile(composeFile, []byte(contents), 0o644)) + + r := New(slog.New(slog.DiscardHandler)) + r.Settings = tooler.NewSettings(io.Discard) + + release := Release{ + Release: domain.Release{Name: stack}, + File: composeFile, + } + + installation := release + installation.Owner = owner + + // One label of the set differing is a different owner. + agent := release + agent.Owner = maps.Clone(owner) + agent.Owner["foundry.signoz.io/kind"] = "CollectionAgent" + + assert.NoError(t, r.Up(context.Background(), installation)) + t.Cleanup(func() { _ = r.Down(context.Background(), installation) }) + waitForStack(t, stack) + + assert.ErrorContains(t, r.Up(context.Background(), agent), "already belongs to") + assert.ErrorContains(t, r.Down(context.Background(), agent), "already belongs to") + assert.NoError(t, r.Down(context.Background(), installation)) +} diff --git a/internal/tooler/dockertooler/tooler.go b/internal/tooler/dockertooler/tooler.go deleted file mode 100644 index 0fcb5098..00000000 --- a/internal/tooler/dockertooler/tooler.go +++ /dev/null @@ -1,27 +0,0 @@ -package dockertooler - -import ( - "context" - - root "github.com/signoz/foundry/internal/tooler" -) - -var _ root.Tooler = (*dockerTooler)(nil) - -type dockerTooler struct{} - -func New() *dockerTooler { - return &dockerTooler{} -} - -func (tooler *dockerTooler) Name() string { - return "docker" -} - -func (tooler *dockerTooler) Gauge(ctx context.Context) error { - return root.ExecChecker(ctx, "docker") -} - -func (tooler *dockerTooler) Install(ctx context.Context) error { - return nil -} diff --git a/internal/tooler/exec.go b/internal/tooler/exec.go new file mode 100644 index 00000000..5a342f8f --- /dev/null +++ b/internal/tooler/exec.go @@ -0,0 +1,116 @@ +package tooler + +import ( + "bytes" + "context" + "io" + "os/exec" + "path/filepath" + "strings" + "time" + + foundryerrors "github.com/signoz/foundry/internal/errors" +) + +// waitForOutput bounds the post-exit wait for stream pipes an orphaned +// grandchild can hold open forever. +const waitForOutput = 10 * time.Second + +type Invocation struct { + // Argv[0] is the resolved binary. Never a shell string: injection is + // structurally impossible. + Argv []string + + Mode Mode +} + +// Everything here is tooler-constructed, so a gap is an internal fault, never +// user input. +func (inv Invocation) Validate() error { + if len(inv.Argv) == 0 { + return foundryerrors.Newf(foundryerrors.TypeInternal, "failed to build invocation: no command is stated") + } + + if inv.Mode.wire == nil { + return foundryerrors.Newf(foundryerrors.TypeInternal, "failed to build invocation for %s: no output mode is stated", inv.Command()) + } + + return nil +} + +// Command renders the invocation the way the user would type it, dropping the +// resolved binary's directory. +func (inv Invocation) Command() string { + if len(inv.Argv) == 0 { + return "" + } + + return strings.Join(append([]string{filepath.Base(inv.Argv[0])}, inv.Argv[1:]...), " ") +} + +type Mode struct { + wire func(cmd *exec.Cmd, sink io.Writer, tail *foundryerrors.Tail) *bytes.Buffer +} + +var ( + // The shared writer makes os/exec serialize the two streams into one. + Stream = Mode{wire: func(cmd *exec.Cmd, sink io.Writer, tail *foundryerrors.Tail) *bytes.Buffer { + out := io.MultiWriter(sink, tail) + cmd.Stdout, cmd.Stderr = out, out + + return nil + }} + + Capture = Mode{wire: func(cmd *exec.Cmd, sink io.Writer, tail *foundryerrors.Tail) *bytes.Buffer { + buf := &bytes.Buffer{} + cmd.Stdout, cmd.Stderr = buf, tail + + return buf + }} + + Quiet = Mode{wire: func(cmd *exec.Cmd, sink io.Writer, tail *foundryerrors.Tail) *bytes.Buffer { + cmd.Stdout, cmd.Stderr = tail, tail + + return nil + }} +) + +type Result struct { + // Output is filled in Capture mode alone. + Output []byte +} + +// Invoke owns every process spawn in foundry, and castings never reach it. A +// tool runs as the user would run it at their shell: same environment, same +// working directory, no clock on its work. +func Invoke(ctx context.Context, settings Settings, inv Invocation) (Result, error) { + if err := inv.Validate(); err != nil { + return Result{}, err + } + + // ctx is unused: the kernel already delivered the interrupt directly. + cmd := exec.Command(inv.Argv[0], inv.Argv[1:]...) + + cmd.WaitDelay = waitForOutput + + tail := &foundryerrors.Tail{} + captured := inv.Mode.wire(cmd, settings.Sink(), tail) + + if err := cmd.Run(); err != nil { + if words := tail.String(); words != "" { + return Result{}, foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to run %s: %s", inv.Command(), words) + } + + return Result{}, foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to run %s", inv.Command()) + } + + if captured == nil { + return Result{}, nil + } + + return Result{Output: captured.Bytes()}, nil +} + +func Resolve(name string) (string, error) { + return exec.LookPath(name) +} diff --git a/internal/tooler/exec_test.go b/internal/tooler/exec_test.go new file mode 100644 index 00000000..db3f3b8d --- /dev/null +++ b/internal/tooler/exec_test.go @@ -0,0 +1,135 @@ +package tooler + +import ( + "bytes" + "context" + "testing" + + foundryerrors "github.com/signoz/foundry/internal/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func binary(t *testing.T, name string) string { + t.Helper() + + path, err := Resolve(name) + require.NoError(t, err) + + return path +} + +func TestCommand(t *testing.T) { + tests := []struct { + name string + argv []string + expectedCommand string + }{ + {name: "ResolvedBinary_Based", argv: []string{"/usr/bin/echo", "cast"}, expectedCommand: "echo cast"}, + {name: "BareBinary_Unchanged", argv: []string{"echo"}, expectedCommand: "echo"}, + {name: "NoArgv_Empty", argv: nil, expectedCommand: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedCommand, Invocation{Argv: tt.argv}.Command()) + }) + } +} + +func TestRunModes(t *testing.T) { + tests := []struct { + name string + mode Mode + expectedSink string + expectedOutput string + }{ + {name: "Stream_SinkAndOutput", mode: Stream, expectedSink: "cast\n", expectedOutput: ""}, + {name: "Capture_OutputOnly", mode: Capture, expectedSink: "", expectedOutput: "cast\n"}, + {name: "Quiet_Neither", mode: Quiet, expectedSink: "", expectedOutput: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sink := &bytes.Buffer{} + + result, err := Invoke(context.Background(), NewSettings(sink), Invocation{ + Argv: []string{binary(t, "echo"), "cast"}, + Mode: tt.mode, + }) + + assert.NoError(t, err) + assert.Equal(t, tt.expectedSink, sink.String()) + assert.Equal(t, tt.expectedOutput, string(result.Output)) + }) + } +} + +func TestRunFailureCarriesTheTail(t *testing.T) { + tests := []struct { + name string + mode Mode + }{ + {name: "Stream_Tailed", mode: Stream}, + {name: "Capture_Tailed", mode: Capture}, + {name: "Quiet_Tailed", mode: Quiet}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Invoke(context.Background(), NewSettings(&bytes.Buffer{}), Invocation{ + Argv: []string{binary(t, "sh"), "-c", "echo the tool said no >&2; exit 3"}, + Mode: tt.mode, + }) + + require.Error(t, err) + + exception := foundryerrors.ExceptionOf(err) + assert.Equal(t, "failed to run sh -c echo the tool said no >&2; exit 3: the tool said no\n", exception.Message) + + require.NotNil(t, exception.Cause) + assert.Equal(t, "exit status 3", exception.Cause.Message) + }) + } +} + +// Stdin is never attached, so a tool that would prompt fails instead of +// hanging on a CI tooler with nothing to answer it. +func TestRunLeavesStdinUnattached(t *testing.T) { + result, err := Invoke(context.Background(), Settings{}, Invocation{ + Argv: []string{binary(t, "sh"), "-c", "cat"}, + Mode: Capture, + }) + + assert.NoError(t, err) + assert.Empty(t, string(result.Output)) +} + +func TestRunIgnoresACancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := Invoke(ctx, Settings{}, Invocation{Argv: []string{binary(t, "echo"), "cast"}, Mode: Capture}) + + assert.NoError(t, err) + assert.Equal(t, "cast\n", string(result.Output)) +} + +func TestRunValidatesTheInvocation(t *testing.T) { + tests := []struct { + name string + invocation Invocation + }{ + {name: "NoMode_Invalid", invocation: Invocation{Argv: []string{binary(t, "echo"), "cast"}}}, + {name: "NoArgv_Invalid", invocation: Invocation{Mode: Capture}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Invoke(context.Background(), Settings{}, tt.invocation) + + require.Error(t, err) + assert.Contains(t, foundryerrors.ExceptionOf(err).Message, "failed to build invocation") + }) + } +} diff --git a/internal/tooler/helmtooler/tooler.go b/internal/tooler/helmtooler/tooler.go index 9a9c027f..4e9d570b 100644 --- a/internal/tooler/helmtooler/tooler.go +++ b/internal/tooler/helmtooler/tooler.go @@ -1,27 +1,324 @@ +// Package helmtooler speaks helm. package helmtooler import ( "context" + "fmt" + "log/slog" + "os" - root "github.com/signoz/foundry/internal/tooler" + "github.com/signoz/foundry/internal/domain" + foundryerrors "github.com/signoz/foundry/internal/errors" + "github.com/signoz/foundry/internal/tooler" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/getter" + helmrelease "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/repo" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/discovery" + memory "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "k8s.io/client-go/transport" ) -var _ root.Tooler = (*helmTooler)(nil) +var _ tooler.Tooler = (*Tooler)(nil) -type helmTooler struct{} +type Repo struct { + Name string + URL string +} + +type Release struct { + domain.Release + + Namespace string + + // Chart is a local chart path or a repo-qualified name ("signoz/signoz"). + Chart string + + Repo Repo + + Values map[string]any + + // The zero value is the ambient kubeconfig, which is also how the + // in-cluster case resolves. + Connection tooler.Connection +} + +func (r Release) Validate() error { + if err := r.Release.Validate(); err != nil { + return err + } + + if r.Namespace == "" { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "failed to validate release: no namespace is stated") + } + + return nil +} -func New() *helmTooler { - return &helmTooler{} +type Tooler struct { + tooler.Tool } -func (tooler *helmTooler) Name() string { - return "helm" +func New(logger *slog.Logger) *Tooler { + return &Tooler{Tool: tooler.NewTool("helm", logger)} } -func (tooler *helmTooler) Gauge(ctx context.Context) error { - return root.ExecChecker(ctx, "helm") +func Lookup(toolers []tooler.Tooler) (*Tooler, error) { + for _, t := range toolers { + if helm, ok := t.(*Tooler); ok { + return helm, nil + } + } + + return nil, foundryerrors.Newf(foundryerrors.TypeNotFound, "failed to look up the helm tooler: it is not registered for this casting") } -func (tooler *helmTooler) Install(ctx context.Context) error { +// Gauge proves a kubeconfig exists and parses, not that a cluster answers: +// reach is a per-verb question. +func (t *Tooler) Gauge(ctx context.Context) error { + if _, err := cli.New().RESTClientGetter().ToRESTConfig(); err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeNotFound, "failed to reach a cluster: no kubeconfig resolved") + } + return nil } + +// Upgrade is helm's own upgrade --install: action.Upgrade.Install is +// informative only, so the caller dispatches install-vs-upgrade. +func (t *Tooler) Upgrade(ctx context.Context, release Release) error { + if err := release.Validate(); err != nil { + return err + } + + if release.Chart == "" { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "failed to run helm upgrade: no chart is stated") + } + + env, config, err := t.configure(release) + if err != nil { + return err + } + + if release.Repo.Name != "" && release.Repo.URL != "" { + if err := addRepo(env, release.Repo); err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to run helm upgrade: could not add repo %q", release.Repo.Name) + } + } + + found, err := t.claim(ctx, config, release.Name, release.Owner) + if err != nil { + return err + } + + if !found { + return t.install(ctx, env, config, release) + } + + return t.upgrade(ctx, env, config, release) +} + +// Uninstall has no context-aware form in the SDK, so ctx is honored at entry +// alone; the removal cannot abort midway. +func (t *Tooler) Uninstall(ctx context.Context, release Release) error { + if err := release.Validate(); err != nil { + return err + } + + if err := ctx.Err(); err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to run helm uninstall") + } + + _, config, err := t.configure(release) + if err != nil { + return err + } + + if _, err := t.claim(ctx, config, release.Name, release.Owner); err != nil { + return err + } + + uninstall := action.NewUninstall(config) + uninstall.Wait = true + + if _, err := uninstall.Run(release.Name); err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to run helm uninstall") + } + + return nil +} + +func (t *Tooler) install(ctx context.Context, env *cli.EnvSettings, config *action.Configuration, release Release) error { + install := action.NewInstall(config) + install.ReleaseName = release.Name + install.Namespace = release.Namespace + install.CreateNamespace = true + install.Wait = true + install.Labels = release.Owner + + chart, err := loadChart(env, install.LocateChart, release.Chart) + if err != nil { + return err + } + + if _, err := install.RunWithContext(ctx, chart, release.Values); err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to run helm install") + } + + return nil +} + +func (t *Tooler) upgrade(ctx context.Context, env *cli.EnvSettings, config *action.Configuration, release Release) error { + upgrade := action.NewUpgrade(config) + upgrade.Install = true + upgrade.Namespace = release.Namespace + upgrade.Wait = true + upgrade.Labels = release.Owner + + chart, err := loadChart(env, upgrade.LocateChart, release.Chart) + if err != nil { + return err + } + + if _, err := upgrade.RunWithContext(ctx, release.Name, chart, release.Values); err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to run helm upgrade") + } + + return nil +} + +func (t *Tooler) claim(ctx context.Context, config *action.Configuration, name string, owner domain.Owner) (bool, error) { + history := action.NewHistory(config) + history.Max = 1 + + releases, err := history.Run(name) + if err != nil || len(releases) == 0 { + return false, nil + } + + return true, t.verify(ctx, releases[len(releases)-1], owner) +} + +// verify compares only the keys foundry stamps: a release also carries helm's +// own system labels. +func (t *Tooler) verify(ctx context.Context, rel *helmrelease.Release, owner domain.Owner) error { + if len(owner) == 0 { + return nil + } + + return tooler.Verify(ctx, t.Tool, domain.Release{Name: rel.Name, Owner: owner}, func(context.Context) (domain.Ownership, error) { + return domain.NewOwnership(owner.Read(rel.Labels)), nil + }) +} + +func loadChart(env *cli.EnvSettings, locate func(string, *cli.EnvSettings) (string, error), ref string) (*chart.Chart, error) { + path, err := locate(ref, env) + if err != nil { + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeNotFound, "failed to locate chart %q", ref) + } + + loaded, err := loader.Load(path) + if err != nil { + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to load chart %q", ref) + } + + return loaded, nil +} + +func addRepo(env *cli.EnvSettings, r Repo) error { + entry := &repo.Entry{Name: r.Name, URL: r.URL} + + chartRepo, err := repo.NewChartRepository(entry, getter.All(env)) + if err != nil { + return err + } + + chartRepo.CachePath = env.RepositoryCache + if _, err := chartRepo.DownloadIndexFile(); err != nil { + return err + } + + file, err := repo.LoadFile(env.RepositoryConfig) + if err != nil { + file = repo.NewFile() + } + + file.Update(entry) + + return file.WriteFile(env.RepositoryConfig, 0o644) +} + +func (t *Tooler) configure(release Release) (*cli.EnvSettings, *action.Configuration, error) { + env := cli.New() + env.SetNamespace(release.Namespace) + + config := new(action.Configuration) + if err := config.Init(clientGetter(env, release), release.Namespace, os.Getenv("HELM_DRIVER"), func(format string, v ...any) { + _, _ = fmt.Fprintf(t.Settings.Sink(), format+"\n", v...) + }); err != nil { + return nil, nil, foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to initialize helm: the cluster is not reachable") + } + + return env, config, nil +} + +// A stated connection means helm never consults a kubeconfig foundry did not +// give it. +func clientGetter(env *cli.EnvSettings, release Release) genericclioptions.RESTClientGetter { + if release.Connection.IsZero() { + return env.RESTClientGetter() + } + + config := &rest.Config{Host: release.Connection.Address().String()} + config.CAData = release.Connection.CA() + + // The token is minted per request, never once: an EKS token outlives + // neither a slow install nor a wait. + config.Wrap(transport.TokenSourceWrapTransport(transport.NewCachedTokenSource(release.Connection.TokenSource()))) + + return restGetter{config: config, namespace: release.Namespace} +} + +type restGetter struct { + config *rest.Config + + namespace string +} + +func (c restGetter) ToRESTConfig() (*rest.Config, error) { + return c.config, nil +} + +func (c restGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) { + client, err := discovery.NewDiscoveryClientForConfig(c.config) + if err != nil { + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to build the kubernetes client") + } + + return memory.NewMemCacheClient(client), nil +} + +func (c restGetter) ToRESTMapper() (meta.RESTMapper, error) { + client, err := c.ToDiscoveryClient() + if err != nil { + return nil, err + } + + return restmapper.NewDeferredDiscoveryRESTMapper(client), nil +} + +// An exact connection has no kubeconfig behind it, so the loader carries the +// namespace alone. +func (c restGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig { + overrides := &clientcmd.ConfigOverrides{Context: clientcmdapi.Context{Namespace: c.namespace}} + + return clientcmd.NewDefaultClientConfig(*clientcmdapi.NewConfig(), overrides) +} diff --git a/internal/tooler/helmtooler/tooler_test.go b/internal/tooler/helmtooler/tooler_test.go new file mode 100644 index 00000000..42faa7f1 --- /dev/null +++ b/internal/tooler/helmtooler/tooler_test.go @@ -0,0 +1,95 @@ +package helmtooler + +import ( + "context" + "log/slog" + "testing" + + "github.com/signoz/foundry/internal/domain" + "github.com/stretchr/testify/assert" + helmrelease "helm.sh/helm/v3/pkg/release" +) + +func TestValidate(t *testing.T) { + complete := Release{ + Release: domain.Release{Name: "signoz", Owner: domain.Owner{"foundry.signoz.io/name": "signoz"}}, + Namespace: "signoz", + Chart: "signoz/signoz", + } + + without := func(mutate func(*Release)) Release { + release := complete + mutate(&release) + + return release + } + + tests := []struct { + name string + release Release + pass bool + }{ + {name: "Complete_Valid", release: complete, pass: true}, + {name: "UnstatedName_Invalid", release: without(func(r *Release) { r.Name = "" })}, + {name: "UnstatedOwner_Invalid", release: without(func(r *Release) { r.Owner = nil })}, + {name: "UnstatedNamespace_Invalid", release: without(func(r *Release) { r.Namespace = "" })}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.release.Validate() + + if !tt.pass { + assert.Error(t, err) + + return + } + + assert.NoError(t, err) + }) + } +} + +func TestOwnerGuard(t *testing.T) { + helm := New(slog.New(slog.DiscardHandler)) + + owner := domain.Owner{ + "foundry.signoz.io/managed-by": "foundry", + "foundry.signoz.io/name": "signoz", + } + + tests := []struct { + name string + labels map[string]string + pass bool + }{ + { + name: "SameOwner_Allowed", + labels: map[string]string{"foundry.signoz.io/managed-by": "foundry", "foundry.signoz.io/name": "signoz", "owner": "helm", "status": "deployed"}, + pass: true, + }, + { + name: "ForeignOwner_Refused", + labels: map[string]string{"foundry.signoz.io/managed-by": "foundry", "foundry.signoz.io/name": "other"}, + pass: false, + }, + { + name: "Unowned_Allowed", + labels: map[string]string{"owner": "helm", "status": "deployed"}, + pass: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := helm.verify(context.Background(), &helmrelease.Release{Name: "signoz", Labels: tt.labels}, owner) + if !tt.pass { + assert.Error(t, err) + + return + } + + assert.NoError(t, err) + }) + } +} diff --git a/internal/tooler/kubectltooler/tooler.go b/internal/tooler/kubectltooler/tooler.go deleted file mode 100644 index 7fd5b922..00000000 --- a/internal/tooler/kubectltooler/tooler.go +++ /dev/null @@ -1,27 +0,0 @@ -package kubectltooler - -import ( - "context" - - root "github.com/signoz/foundry/internal/tooler" -) - -var _ root.Tooler = (*kubectlTooler)(nil) - -type kubectlTooler struct{} - -func New() *kubectlTooler { - return &kubectlTooler{} -} - -func (t *kubectlTooler) Name() string { - return "kubectl" -} - -func (t *kubectlTooler) Gauge(ctx context.Context) error { - return root.ExecChecker(ctx, "kubectl") -} - -func (t *kubectlTooler) Install(ctx context.Context) error { - return nil -} diff --git a/internal/tooler/kubetooler/tooler.go b/internal/tooler/kubetooler/tooler.go new file mode 100644 index 00000000..0a88284f --- /dev/null +++ b/internal/tooler/kubetooler/tooler.go @@ -0,0 +1,391 @@ +// Package kubetooler speaks the Kubernetes API. +package kubetooler + +import ( + "context" + "io" + "log/slog" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + utilyaml "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/discovery" + memory "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/transport" + "sigs.k8s.io/kustomize/api/krusty" + "sigs.k8s.io/kustomize/kyaml/filesys" + + "github.com/signoz/foundry/internal/domain" + foundryerrors "github.com/signoz/foundry/internal/errors" + "github.com/signoz/foundry/internal/tooler" +) + +var _ tooler.Tooler = (*Tooler)(nil) + +type Release struct { + domain.Release + + Namespace string + + // Dir is the kustomize root rendered and applied. + Dir string + + // FieldManager is who the API server records as owning the fields this + // apply sets. The casting composes it, so two castings never contend over + // a cluster-scoped object. + FieldManager string + + // The zero value is the ambient kubeconfig, which is also how the + // in-cluster case resolves. + Connection tooler.Connection +} + +func (r Release) Validate() error { + if err := r.Release.Validate(); err != nil { + return err + } + + if r.Namespace == "" { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "failed to validate release: no namespace is stated") + } + + if r.Dir == "" { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "failed to validate release: no directory is stated") + } + + if r.FieldManager == "" { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "failed to validate release: no field manager is stated") + } + + return nil +} + +type Tooler struct { + tooler.Tool + + // client is the dialed cluster, memoized by dial: every verb of a run + // speaks the one connection the casting states. + client *client +} + +func New(logger *slog.Logger) *Tooler { + return &Tooler{Tool: tooler.NewTool("kubernetes", logger)} +} + +func Lookup(toolers []tooler.Tooler) (*Tooler, error) { + for _, t := range toolers { + if kube, ok := t.(*Tooler); ok { + return kube, nil + } + } + + return nil, foundryerrors.Newf(foundryerrors.TypeNotFound, "failed to look up the kubernetes tooler: it is not registered for this casting") +} + +// Gauge proves a kubeconfig exists and parses, not that a cluster answers: +// reach is a per-verb question. +func (t *Tooler) Gauge(ctx context.Context) error { + _, err := t.restConfig(tooler.Connection{}) + + return err +} + +// Apply writes the objects in the order the render produced: which objects +// depend on which is the casting's knowledge, stated by its pour structure. +func (t *Tooler) Apply(ctx context.Context, release Release) error { + if err := release.Validate(); err != nil { + return err + } + + objects, err := render(release.Dir) + if err != nil { + return err + } + + client, err := t.connection(release.Connection) + if err != nil { + return err + } + + if err := tooler.Verify(ctx, t.Tool, release.Release, func(ctx context.Context) (domain.Ownership, error) { + return t.read(ctx, release, client, objects) + }); err != nil { + return err + } + + return t.apply(ctx, release, client, objects) +} + +// Delete removes what this release declares, less the kinds that carry data: +// melt never removes data. +func (t *Tooler) Delete(ctx context.Context, release Release) error { + if err := release.Validate(); err != nil { + return err + } + + objects, err := render(release.Dir) + if err != nil { + return err + } + + client, err := t.connection(release.Connection) + if err != nil { + return err + } + + if err := tooler.Verify(ctx, t.Tool, release.Release, func(ctx context.Context) (domain.Ownership, error) { + return t.read(ctx, release, client, objects) + }); err != nil { + return err + } + + // Background is what kubectl delete sends: the server's own default + // orphans a Job's pods, leaving them running with nothing owning them. + policy := metav1.DeletePropagationBackground + + for _, object := range objects { + if undeletable(object.GetKind()) { + continue + } + + resource, err := client.resourceFor(object, release.Namespace) + if err != nil { + return err + } + + t.Logger.DebugContext(ctx, "deleting object", + slog.String("kind", object.GetKind()), + slog.String("name", object.GetName()), + ) + + if err := resource.Delete(ctx, object.GetName(), metav1.DeleteOptions{PropagationPolicy: &policy}); err != nil && !apierrors.IsNotFound(err) { + return foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to delete %s %q", object.GetKind(), object.GetName()) + } + } + + return nil +} + +// Owners reads the release's footprint back from the objects it declares; +// asking every kind the cluster serves is not affordable. +func (t *Tooler) Owners(ctx context.Context, release Release) (domain.Ownership, error) { + if err := release.Release.Validate(); err != nil { + return domain.Ownership{}, err + } + + objects, err := render(release.Dir) + if err != nil { + return domain.Ownership{}, err + } + + client, err := t.connection(release.Connection) + if err != nil { + return domain.Ownership{}, err + } + + return t.read(ctx, release, client, objects) +} + +func (t *Tooler) apply(ctx context.Context, release Release, client *client, objects []*unstructured.Unstructured) error { + for _, object := range objects { + resource, err := client.resourceFor(object, release.Namespace) + if err != nil { + return err + } + + t.Logger.DebugContext(ctx, "applying object", + slog.String("kind", object.GetKind()), + slog.String("name", object.GetName()), + slog.String("fieldManager", release.FieldManager), + ) + + // Force resolves conflicts toward the document: the pours are the + // declared state, not the cluster. + options := metav1.ApplyOptions{FieldManager: release.FieldManager, Force: true} + + if _, err := resource.Apply(ctx, object.GetName(), object, options); err != nil { + return foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to apply %s %q", object.GetKind(), object.GetName()) + } + } + + return nil +} + +// The declared objects are read by identity, never by the caller's own labels: +// selecting on the owner would hide exactly the foreign owner the check exists +// to find. +func (t *Tooler) read(ctx context.Context, release Release, client *client, objects []*unstructured.Unstructured) (domain.Ownership, error) { + owners := make([]domain.Owner, 0, len(objects)) + for _, object := range objects { + resource, err := client.resourceFor(object, release.Namespace) + if err != nil { + // A kind the cluster does not serve yet holds nothing of ours. + continue + } + + found, err := resource.Get(ctx, object.GetName(), metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) || apierrors.IsForbidden(err) { + continue + } + + return domain.Ownership{}, foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to read %s %q", object.GetKind(), object.GetName()) + } + + owners = append(owners, release.Owner.Read(found.GetLabels())) + } + + return domain.NewOwnership(owners...), nil +} + +func render(dir string) ([]*unstructured.Unstructured, error) { + resources, err := krusty.MakeKustomizer(krusty.MakeDefaultOptions()).Run(filesys.MakeFsOnDisk(), dir) + if err != nil { + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeInvalidInput, "failed to render the kustomization at %q", dir) + } + + rendered, err := resources.AsYaml() + if err != nil { + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to render the kustomization at %q", dir) + } + + decoder := utilyaml.NewYAMLToJSONDecoder(strings.NewReader(string(rendered))) + + objects := []*unstructured.Unstructured{} + for { + object := &unstructured.Unstructured{} + + if err := decoder.Decode(object); err != nil { + if err == io.EOF { + break + } + + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to read the rendered kustomization at %q", dir) + } + + if len(object.Object) == 0 { + continue + } + + objects = append(objects, object) + } + + return objects, nil +} + +// undeletable reports a kind Delete leaves standing whatever the casting +// declares: a Namespace or a PersistentVolumeClaim takes the data with it, +// and a CustomResourceDefinition is shared with every release in the cluster. +func undeletable(kind string) bool { + switch kind { + case "CustomResourceDefinition", "Namespace", "PersistentVolumeClaim": + return true + default: + return false + } +} + +type client struct { + dynamic dynamic.Interface + discovery discovery.CachedDiscoveryInterface + mapper *restmapper.DeferredDiscoveryRESTMapper +} + +// The memo is unguarded; foundry runs single-threaded, and a lock would claim a +// concurrency contract toolers do not have. +func (t *Tooler) connection(connection tooler.Connection) (*client, error) { + if t.client != nil { + return t.client, nil + } + + config, err := t.restConfig(connection) + if err != nil { + return nil, err + } + + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to build the kubernetes client") + } + + discoveryClient, err := discovery.NewDiscoveryClientForConfig(config) + if err != nil { + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeInternal, "failed to build the kubernetes client") + } + + cached := memory.NewMemCacheClient(discoveryClient) + + t.client = &client{ + dynamic: dynamicClient, + discovery: cached, + mapper: restmapper.NewDeferredDiscoveryRESTMapper(cached), + } + + return t.client, nil +} + +// An unstated connection is the ambient kubeconfig; a stated one is built from +// scratch. The API server's warnings are the tool's own words either way, so +// they go where every other tool's output goes rather than to client-go's +// default logger. +func (t *Tooler) restConfig(connection tooler.Connection) (*rest.Config, error) { + var config *rest.Config + + if connection.IsZero() { + resolved, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), + &clientcmd.ConfigOverrides{}, + ).ClientConfig() + if err != nil { + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeNotFound, "failed to reach a cluster: no kubeconfig resolved") + } + + config = resolved + } else { + config = &rest.Config{Host: connection.Address().String()} + config.CAData = connection.CA() + + // The token is minted per request, never once: an EKS token outlives + // neither a slow apply nor a wait. + config.Wrap(transport.TokenSourceWrapTransport(transport.NewCachedTokenSource(connection.TokenSource()))) + } + + config.WarningHandler = rest.NewWarningWriter(t.Settings.Sink(), rest.WarningWriterOptions{Deduplicate: true}) + + return config, nil +} + +// An object the render already placed is addressed where it says it lives: the +// path and the body must agree or the API server refuses the write. +func (c *client) resourceFor(object *unstructured.Unstructured, namespace string) (dynamic.ResourceInterface, error) { + if placed := object.GetNamespace(); placed != "" { + namespace = placed + } + + gvk := object.GroupVersionKind() + + mapping, err := c.mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + // A definition applied moments ago is not in the caches yet; one + // refresh tells staleness apart from a kind the cluster does not serve. + c.discovery.Invalidate() + c.mapper.Reset() + + if mapping, err = c.mapper.RESTMapping(gvk.GroupKind(), gvk.Version); err != nil { + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeNotFound, "failed to find the kubernetes resource for %s", gvk.Kind) + } + } + + if mapping.Scope.Name() == meta.RESTScopeNameRoot { + return c.dynamic.Resource(mapping.Resource), nil + } + + return c.dynamic.Resource(mapping.Resource).Namespace(namespace), nil +} diff --git a/internal/tooler/kubetooler/tooler_test.go b/internal/tooler/kubetooler/tooler_test.go new file mode 100644 index 00000000..dddd631a --- /dev/null +++ b/internal/tooler/kubetooler/tooler_test.go @@ -0,0 +1,279 @@ +package kubetooler + +import ( + "context" + "log/slog" + "maps" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/client-go/discovery" + + "github.com/signoz/foundry/internal/domain" + "github.com/signoz/foundry/internal/tooler" +) + +// requireCluster resolves the connection the tooler's own way, so a test never +// passes against a cluster the verbs could not have reached. +func requireCluster(t *testing.T) { + t.Helper() + + if testing.Short() { + t.Skip("skipping kubernetes test in short mode") + } + + config, err := New(slog.New(slog.DiscardHandler)).restConfig(tooler.Connection{}) + if err != nil { + t.Skip("no kubeconfig resolved") + } + + client, err := discovery.NewDiscoveryClientForConfig(config) + if err != nil { + t.Skip("no kubernetes client") + } + + if _, err := client.ServerVersion(); err != nil { + t.Skip("cluster is not reachable") + } +} + +type otherTooler struct{} + +func (otherTooler) Name() string { return "other" } +func (otherTooler) Gauge(_ context.Context) error { return nil } + +func TestLookup(t *testing.T) { + kube := New(slog.New(slog.DiscardHandler)) + + tests := []struct { + name string + toolers []tooler.Tooler + pass bool + }{ + {name: "Only_Valid", toolers: []tooler.Tooler{kube}, pass: true}, + {name: "AmongOthers_Valid", toolers: []tooler.Tooler{otherTooler{}, kube}, pass: true}, + {name: "Empty_Invalid", toolers: nil, pass: false}, + {name: "OnlyOthers_Invalid", toolers: []tooler.Tooler{otherTooler{}}, pass: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + found, err := Lookup(tt.toolers) + + if !tt.pass { + assert.Error(t, err) + assert.Nil(t, found) + + return + } + + assert.NoError(t, err) + assert.Same(t, kube, found) + }) + } +} + +func TestValidate(t *testing.T) { + owner := domain.Owner{"foundry.signoz.io/name": "signoz"} + + complete := Release{ + Release: domain.Release{Name: "signoz", Owner: owner}, + Namespace: "signoz", + Dir: "pours/deployment", + FieldManager: "foundry-installation", + } + + without := func(mutate func(*Release)) Release { + release := complete + mutate(&release) + + return release + } + + tests := []struct { + name string + release Release + pass bool + }{ + {name: "Complete_Valid", release: complete, pass: true}, + {name: "UnstatedName_Invalid", release: without(func(r *Release) { r.Name = "" })}, + {name: "UnstatedOwner_Invalid", release: without(func(r *Release) { r.Owner = nil })}, + {name: "UnstatedNamespace_Invalid", release: without(func(r *Release) { r.Namespace = "" })}, + {name: "UnstatedDirectory_Invalid", release: without(func(r *Release) { r.Dir = "" })}, + {name: "UnstatedFieldManager_Invalid", release: without(func(r *Release) { r.FieldManager = "" })}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.release.Validate() + + if !tt.pass { + assert.Error(t, err) + + return + } + + assert.NoError(t, err) + }) + } +} + +func TestUndeletable(t *testing.T) { + tests := []struct { + name string + kind string + expectedStanding bool + }{ + {name: "Namespace_Standing", kind: "Namespace", expectedStanding: true}, + {name: "PersistentVolumeClaim_Standing", kind: "PersistentVolumeClaim", expectedStanding: true}, + {name: "CustomResourceDefinition_Standing", kind: "CustomResourceDefinition", expectedStanding: true}, + {name: "ConfigMap_Removed", kind: "ConfigMap"}, + {name: "Empty_Removed", kind: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedStanding, undeletable(tt.kind)) + }) + } +} + +func root(t *testing.T, name string, owner domain.Owner) string { + t.Helper() + + dir := t.TempDir() + + pairs := strings.Builder{} + for _, key := range []string{"foundry.signoz.io/kind", "foundry.signoz.io/managed-by", "foundry.signoz.io/name"} { + if value, ok := owner[key]; ok { + pairs.WriteString(" " + key + ": " + value + "\n") + } + } + + kustomization := "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n" + + "resources:\n- configmap.yaml\n" + + "labels:\n- includeSelectors: false\n pairs:\n" + pairs.String() + + configmap := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: " + name + "\ndata:\n ok: \"true\"\n" + + require.NoError(t, os.WriteFile(filepath.Join(dir, "kustomization.yaml"), []byte(kustomization), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "configmap.yaml"), []byte(configmap), 0o644)) + + return dir +} + +func TestRender(t *testing.T) { + owner := domain.Owner{"foundry.signoz.io/name": "kubetooler-test"} + + tests := []struct { + name string + dir string + pass bool + expectedKind string + expectedName string + }{ + { + name: "Root_Valid", + dir: root(t, "kubetooler-test", owner), + pass: true, + expectedKind: "ConfigMap", + expectedName: "kubetooler-test", + }, + { + name: "UnstatedRoot_Invalid", + dir: filepath.Join(t.TempDir(), "absent"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + objects, err := render(tt.dir) + if !tt.pass { + assert.Error(t, err) + + return + } + + require.NoError(t, err) + require.Len(t, objects, 1) + + assert.Equal(t, tt.expectedKind, objects[0].GetKind()) + assert.Equal(t, tt.expectedName, objects[0].GetName()) + assert.Equal(t, tt.expectedName, objects[0].GetLabels()["foundry.signoz.io/name"]) + }) + } +} + +func TestApplyDelete(t *testing.T) { + requireCluster(t) + + const name = "kubetooler-test" + + owner := domain.Owner{ + "foundry.signoz.io/managed-by": "foundry", + "foundry.signoz.io/kind": "Installation", + "foundry.signoz.io/name": name, + } + + kube := New(slog.New(slog.DiscardHandler)) + + release := Release{ + Release: domain.Release{Name: name, Owner: owner}, + Namespace: "default", + Dir: root(t, name, owner), + FieldManager: "foundry-installation", + } + + require.NoError(t, kube.Apply(context.Background(), release)) + t.Cleanup(func() { _ = kube.Delete(context.Background(), release) }) + + ownership, err := kube.Owners(context.Background(), release) + require.NoError(t, err) + + _, conflict := ownership.Foreign(owner) + assert.False(t, conflict) + + assert.NoError(t, kube.Delete(context.Background(), release)) +} + +// An object labelled for one owner is refused to another, and granted back to +// the owner that holds it. +func TestOwnerGuardsTheRelease(t *testing.T) { + requireCluster(t) + + const name = "kubetooler-owner-test" + + owner := domain.Owner{ + "foundry.signoz.io/managed-by": "foundry", + "foundry.signoz.io/kind": "Installation", + "foundry.signoz.io/name": name, + } + + kube := New(slog.New(slog.DiscardHandler)) + + installation := Release{ + Release: domain.Release{Name: name, Owner: owner}, + Namespace: "default", + Dir: root(t, name, owner), + FieldManager: "foundry-installation", + } + + // One key of the set differing is a different owner. + foreign := maps.Clone(owner) + foreign["foundry.signoz.io/kind"] = "CollectionAgent" + + agent := installation + agent.Owner = foreign + agent.FieldManager = "foundry-collectionagent" + + require.NoError(t, kube.Apply(context.Background(), installation)) + t.Cleanup(func() { _ = kube.Delete(context.Background(), installation) }) + + assert.ErrorContains(t, kube.Apply(context.Background(), agent), "already belongs to") + assert.ErrorContains(t, kube.Delete(context.Background(), agent), "already belongs to") + assert.NoError(t, kube.Delete(context.Background(), installation)) +} diff --git a/internal/tooler/ownership.go b/internal/tooler/ownership.go new file mode 100644 index 00000000..74ca94ce --- /dev/null +++ b/internal/tooler/ownership.go @@ -0,0 +1,30 @@ +package tooler + +import ( + "context" + "log/slog" + + "github.com/signoz/foundry/internal/domain" + foundryerrors "github.com/signoz/foundry/internal/errors" +) + +// Verify skips an unreadable ownership list rather than blocking: the verb +// itself then fails loudly, in the tool's own words. +func Verify(ctx context.Context, tool Tool, release domain.Release, list func(context.Context) (domain.Ownership, error)) error { + ownership, err := list(ctx) + if err != nil { + tool.Logger.WarnContext(ctx, "skipping the ownership check: could not read labels", slog.String("tool", tool.Name()), foundryerrors.LogAttr(err)) + + return nil + } + + if foreign, conflict := ownership.Foreign(release.Owner); conflict { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "failed to run %s: %q already belongs to [%s], not [%s]: remove it, or deploy under a different name", tool.Name(), release.Name, foreign, release.Owner) + } + + if ownership.HasUnowned() { + tool.Logger.WarnContext(ctx, "release has objects without ownership labels", slog.String("tool", tool.Name()), slog.String("release", release.Name)) + } + + return nil +} diff --git a/internal/tooler/settings.go b/internal/tooler/settings.go new file mode 100644 index 00000000..b657abb1 --- /dev/null +++ b/internal/tooler/settings.go @@ -0,0 +1,24 @@ +package tooler + +import ( + "io" + "os" +) + +// Settings' zero value streams to stderr. +type Settings struct { + sink io.Writer +} + +func NewSettings(sink io.Writer) Settings { + return Settings{sink: sink} +} + +// Sink is where the tool's own output goes; only tests point it elsewhere. +func (s Settings) Sink() io.Writer { + if s.sink == nil { + return os.Stderr + } + + return s.sink +} diff --git a/internal/tooler/systemdtooler/tooler.go b/internal/tooler/systemdtooler/tooler.go index f8133dff..2cc0d5c3 100644 --- a/internal/tooler/systemdtooler/tooler.go +++ b/internal/tooler/systemdtooler/tooler.go @@ -1,27 +1,143 @@ +// Package systemdtooler speaks systemctl. package systemdtooler import ( "context" + "log/slog" + "path/filepath" + "slices" - root "github.com/signoz/foundry/internal/tooler" + "github.com/signoz/foundry/internal/domain" + foundryerrors "github.com/signoz/foundry/internal/errors" + "github.com/signoz/foundry/internal/tooler" ) -var _ root.Tooler = (*systemdTooler)(nil) +var _ tooler.Tooler = (*Tooler)(nil) -type systemdTooler struct{} +type Release struct { + domain.Release -func New() *systemdTooler { - return &systemdTooler{} + // Units are the *.service file paths enabled, started, and stopped. + Units []string } -func (tooler *systemdTooler) Name() string { - return "systemd" +func (r Release) Validate() error { + if err := r.Release.Validate(); err != nil { + return err + } + + if len(r.Units) == 0 { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "failed to validate release: no units are stated") + } + + return nil } -func (tooler *systemdTooler) Gauge(ctx context.Context) error { - return root.ExecChecker(ctx, "systemctl") +type Tooler struct { + tooler.Tool + + // words is the resolved command prefix, memoized by command. + words []string } -func (tooler *systemdTooler) Install(ctx context.Context) error { - return nil +func New(logger *slog.Logger) *Tooler { + return &Tooler{Tool: tooler.NewTool("systemctl", logger)} +} + +func Lookup(toolers []tooler.Tooler) (*Tooler, error) { + for _, t := range toolers { + if systemd, ok := t.(*Tooler); ok { + return systemd, nil + } + } + + return nil, foundryerrors.Newf(foundryerrors.TypeNotFound, "failed to look up the systemd tooler: it is not registered for this casting") +} + +func (t *Tooler) Gauge(ctx context.Context) error { + _, err := t.command(ctx) + + return err +} + +// Up starts units with --no-block: it returns before they converge. +func (t *Tooler) Up(ctx context.Context, release Release) error { + if err := t.run(ctx, release, "enable", release.Units...); err != nil { + return err + } + + if err := t.run(ctx, release, "daemon-reload"); err != nil { + return err + } + + return t.run(ctx, release, "start", append([]string{"--no-block"}, names(release.Units)...)...) +} + +// Down stops and disables the units; the unit files and provisioned state stay. +func (t *Tooler) Down(ctx context.Context, release Release) error { + if err := t.run(ctx, release, "stop", names(release.Units)...); err != nil { + return err + } + + if err := t.run(ctx, release, "disable", names(release.Units)...); err != nil { + return err + } + + return t.run(ctx, release, "daemon-reload") +} + +func (t *Tooler) run(ctx context.Context, release Release, verb string, args ...string) error { + if err := release.Validate(); err != nil { + return err + } + + words, err := t.command(ctx) + if err != nil { + return err + } + + argv := append(slices.Clone(words), verb) + argv = append(argv, args...) + + inv := tooler.Invocation{Argv: argv, Mode: tooler.Stream} + + t.Logger.DebugContext(ctx, "running command", slog.String("command", inv.Command())) + + _, err = tooler.Invoke(ctx, t.Settings, inv) + + return err +} + +// enable takes unit paths; start and stop take unit names. +func names(units []string) []string { + out := make([]string, 0, len(units)) + for _, unit := range units { + out = append(out, filepath.Base(unit)) + } + + return out +} + +// The memo is unguarded; foundry runs single-threaded, and a lock would claim a +// concurrency contract toolers do not have. +func (t *Tooler) command(ctx context.Context) ([]string, error) { + if len(t.words) != 0 { + return t.words, nil + } + + path, err := tooler.Resolve("systemctl") + if err != nil { + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeNotFound, "failed to find systemctl: this host is not running systemd") + } + + if _, err := tooler.Invoke(ctx, t.Settings, tooler.Invocation{ + Argv: []string{path, "--version"}, + Mode: tooler.Capture, + }); err != nil { + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeNotFound, "failed to find systemctl: this host is not running systemd") + } + + t.words = []string{path} + + return t.words, nil } diff --git a/internal/tooler/systemdtooler/tooler_test.go b/internal/tooler/systemdtooler/tooler_test.go new file mode 100644 index 00000000..3d90496e --- /dev/null +++ b/internal/tooler/systemdtooler/tooler_test.go @@ -0,0 +1,72 @@ +package systemdtooler + +import ( + "context" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/signoz/foundry/internal/domain" + "github.com/signoz/foundry/internal/tooler" + "github.com/stretchr/testify/assert" +) + +func requireSystemd(t *testing.T) { + t.Helper() + + if testing.Short() { + t.Skip("skipping systemd test in short mode") + } + + if _, err := exec.LookPath("systemctl"); err != nil { + t.Skip("systemctl is not available") + } + + if os.Geteuid() != 0 { + t.Skip("systemd test needs root") + } +} + +func TestNames(t *testing.T) { + tests := []struct { + name string + units []string + expectedNames []string + }{ + {name: "None_Empty", units: nil, expectedNames: []string{}}, + {name: "PathsAndNames_Based", units: []string{"/etc/systemd/system/a.service", "b.service"}, expectedNames: []string{"a.service", "b.service"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedNames, names(tt.units)) + }) + } +} + +func TestUpDown(t *testing.T) { + requireSystemd(t) + + unit := filepath.Join(t.TempDir(), "systemdtooler-test.service") + contents := "[Unit]\nDescription=systemdtooler test\n[Service]\nType=oneshot\nExecStart=/bin/true\nRemainAfterExit=yes\n" + assert.NoError(t, os.WriteFile(unit, []byte(contents), 0o644)) + + r := New(slog.New(slog.DiscardHandler)) + r.Settings = tooler.NewSettings(io.Discard) + + release := Release{ + Release: domain.Release{ + Name: "systemdtooler-test", + Owner: domain.Owner{"foundry.signoz.io/managed-by": "foundry"}, + }, + Units: []string{unit}, + } + + assert.NoError(t, r.Gauge(context.Background())) + assert.NoError(t, r.Up(context.Background(), release)) + t.Cleanup(func() { _ = r.Down(context.Background(), release) }) + assert.NoError(t, r.Down(context.Background(), release)) +} diff --git a/internal/tooler/terraformtooler/tooler.go b/internal/tooler/terraformtooler/tooler.go index 765fc65e..00f966c7 100644 --- a/internal/tooler/terraformtooler/tooler.go +++ b/internal/tooler/terraformtooler/tooler.go @@ -1,30 +1,153 @@ +// Package terraformtooler speaks terraform. package terraformtooler import ( "context" + "log/slog" + "slices" - root "github.com/signoz/foundry/internal/tooler" + "github.com/signoz/foundry/internal/domain" + foundryerrors "github.com/signoz/foundry/internal/errors" + "github.com/signoz/foundry/internal/tooler" ) -var _ root.Tooler = (*terraformTooler)(nil) +var _ tooler.Tooler = (*Tooler)(nil) -type terraformTooler struct{} +type Release struct { + domain.Release -func New() *terraformTooler { - return &terraformTooler{} + Root string } -func (t *terraformTooler) Name() string { - return "terraform" +func (r Release) Validate() error { + if err := r.Release.Validate(); err != nil { + return err + } + + if r.Root == "" { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "failed to validate release: no root is stated") + } + + return nil } -func (t *terraformTooler) Gauge(ctx context.Context) error { - return root.ExecChecker(ctx, "terraform") +type Tooler struct { + tooler.Tool + + // words is the resolved command prefix, memoized by command. + words []string } -func (t *terraformTooler) Install(ctx context.Context) error { - // Terraform installation is platform-specific and typically requires manual installation - // or use of a package manager. We return nil here as users are expected to have - // terraform installed. - return nil +func New(logger *slog.Logger) *Tooler { + return &Tooler{Tool: tooler.NewTool("terraform", logger)} +} + +func Lookup(toolers []tooler.Tooler) (*Tooler, error) { + for _, t := range toolers { + if terraform, ok := t.(*Tooler); ok { + return terraform, nil + } + } + + return nil, foundryerrors.Newf(foundryerrors.TypeNotFound, "failed to look up the terraform tooler: it is not registered for this casting") +} + +func (t *Tooler) Gauge(ctx context.Context) error { + _, err := t.command(ctx) + + return err +} + +// PlanFile is the plan terraform writes in the root, read with terraform show. +const PlanFile = "tfplan" + +// Apply acts on a written plan: planning and applying in one step prints the +// whole diff, and a plan is a document about a change, not a log of one. +func (t *Tooler) Apply(ctx context.Context, release Release) error { + // terraform prompts before changing infra; foundry re-homes that to --yes. + if !tooler.Approved(ctx) { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "failed to run terraform apply: no approval is stated; re-run with --yes") + } + + if err := t.query(ctx, release, "init"); err != nil { + return err + } + + if err := t.query(ctx, release, "plan", "-out="+PlanFile); err != nil { + return err + } + + return t.run(ctx, release, "apply", PlanFile) +} + +func (t *Tooler) Destroy(ctx context.Context, release Release) error { + if !tooler.Approved(ctx) { + return foundryerrors.Newf(foundryerrors.TypeInvalidInput, "failed to run terraform destroy: no approval is stated; re-run with --yes") + } + + if err := t.query(ctx, release, "init"); err != nil { + return err + } + + if err := t.query(ctx, release, "plan", "-destroy", "-out="+PlanFile); err != nil { + return err + } + + return t.run(ctx, release, "apply", PlanFile) +} + +func (t *Tooler) run(ctx context.Context, release Release, verb string, args ...string) error { + return t.invoke(ctx, release, tooler.Stream, verb, args...) +} + +// query reads the world, so it keeps only enough output to explain a failure. +func (t *Tooler) query(ctx context.Context, release Release, verb string, args ...string) error { + return t.invoke(ctx, release, tooler.Quiet, verb, args...) +} + +func (t *Tooler) invoke(ctx context.Context, release Release, mode tooler.Mode, verb string, args ...string) error { + if err := release.Validate(); err != nil { + return err + } + + words, err := t.command(ctx) + if err != nil { + return err + } + + // -chdir tells terraform the root; foundry never changes its own cwd. + argv := append(slices.Clone(words), "-chdir="+release.Root, verb) + argv = append(argv, args...) + + inv := tooler.Invocation{Argv: argv, Mode: mode} + + t.Logger.DebugContext(ctx, "running command", slog.String("command", inv.Command())) + + _, err = tooler.Invoke(ctx, t.Settings, inv) + + return err +} + +// The memo is unguarded; foundry runs single-threaded, and a lock would claim a +// concurrency contract toolers do not have. +func (t *Tooler) command(ctx context.Context) ([]string, error) { + if len(t.words) != 0 { + return t.words, nil + } + + path, err := tooler.Resolve("terraform") + if err != nil { + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeNotFound, "failed to find terraform: install it from https://developer.hashicorp.com/terraform/install") + } + + if _, err := tooler.Invoke(ctx, t.Settings, tooler.Invocation{ + Argv: []string{path, "version"}, + Mode: tooler.Capture, + }); err != nil { + return nil, foundryerrors.Wrapf(err, foundryerrors.TypeNotFound, "failed to find terraform: install it from https://developer.hashicorp.com/terraform/install") + } + + t.words = []string{path} + + return t.words, nil } diff --git a/internal/tooler/terraformtooler/tooler_test.go b/internal/tooler/terraformtooler/tooler_test.go new file mode 100644 index 00000000..93d6c8a3 --- /dev/null +++ b/internal/tooler/terraformtooler/tooler_test.go @@ -0,0 +1,107 @@ +package terraformtooler + +import ( + "context" + "log/slog" + "testing" + + "github.com/signoz/foundry/internal/domain" + "github.com/signoz/foundry/internal/tooler" + "github.com/stretchr/testify/assert" +) + +type otherTooler struct{} + +func (otherTooler) Name() string { return "other" } +func (otherTooler) Gauge(_ context.Context) error { return nil } + +func TestLookup(t *testing.T) { + terraform := New(slog.New(slog.DiscardHandler)) + + tests := []struct { + name string + toolers []tooler.Tooler + pass bool + }{ + {name: "Only_Valid", toolers: []tooler.Tooler{terraform}, pass: true}, + {name: "AmongOthers_Valid", toolers: []tooler.Tooler{otherTooler{}, terraform}, pass: true}, + {name: "Empty_Invalid", toolers: nil}, + {name: "OnlyOthers_Invalid", toolers: []tooler.Tooler{otherTooler{}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + found, err := Lookup(tt.toolers) + + if !tt.pass { + assert.Error(t, err) + assert.Nil(t, found) + + return + } + + assert.NoError(t, err) + assert.Same(t, terraform, found) + }) + } +} + +func TestValidate(t *testing.T) { + complete := Release{ + Release: domain.Release{Name: "signoz", Owner: domain.Owner{"foundry.signoz.io/name": "signoz"}}, + Root: "pours/infrastructure", + } + + without := func(mutate func(*Release)) Release { + release := complete + mutate(&release) + + return release + } + + tests := []struct { + name string + release Release + pass bool + }{ + {name: "Complete_Valid", release: complete, pass: true}, + {name: "UnstatedName_Invalid", release: without(func(r *Release) { r.Name = "" })}, + {name: "UnstatedOwner_Invalid", release: without(func(r *Release) { r.Owner = nil })}, + {name: "UnstatedRoot_Invalid", release: without(func(r *Release) { r.Root = "" })}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.release.Validate() + + if !tt.pass { + assert.Error(t, err) + + return + } + + assert.NoError(t, err) + }) + } +} + +// The mutation verbs refuse an unapproved context before they invoke anything, +// which is the only place --yes is enforced. The release is deliberately +// unstated: reaching Validate at all would mean the gate was passed. +func TestVerbsRefuseWithoutApproval(t *testing.T) { + terraform := New(slog.New(slog.DiscardHandler)) + + tests := []struct { + name string + verb func(context.Context, Release) error + }{ + {name: "Apply_Invalid", verb: terraform.Apply}, + {name: "Destroy_Invalid", verb: terraform.Destroy}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.ErrorContains(t, tt.verb(context.Background(), Release{}), "re-run with --yes") + }) + } +} diff --git a/internal/tooler/tooler.go b/internal/tooler/tooler.go index 1c1e0e65..959656fd 100644 --- a/internal/tooler/tooler.go +++ b/internal/tooler/tooler.go @@ -1,46 +1,49 @@ +// Package tooler is how foundry speaks to the tools that carry out a +// deployment: docker compose, helm, terraform, systemctl, the Kubernetes and +// AWS APIs. One package per tool; castings drive toolers and toolers drive +// tools. A tooler knows nothing about castings or kinds, so everything a call +// depends on arrives in the verb's arguments. +// +// A tool is reached in one of two shapes. An exec tooler runs a binary through +// Invoke, which states the rules that spawning carries. An SDK tooler calls a +// client library in-process and honours the cancelled context itself, since no +// interrupt is delivered to it. +// +// Verbs are the tool's real operations, one to one. A Mutation acts on a +// Release: it validates, probes the tool, verifies the ownership labels +// stamped into the pours, then invokes. Those labels and the platform's own +// record are the only deployment state there is. A Query reads the world +// instead, and never waits on approval. +// +// Release, Tooler and Connection are the core. A Release is constructed per +// call and never stored, a Tooler holds only the tool's name and settings, and +// connection and approval are ambient rather than arguments. package tooler import ( "context" - "errors" - "os/exec" - "strings" + "log/slog" ) type Tooler interface { - // Name of the tool. Name() string - // Check whether the tool is available on the system. - Gauge(context.Context) error - - // Installs the tool on the system. - Install(context.Context) error -} - -func ExecChecker(ctx context.Context, toolName string) error { - _, err := exec.LookPath(toolName) - return err + Gauge(ctx context.Context) error } -func MultiExecChecker(ctx context.Context, toolNames ...string) error { - var errs []error +// Tool is embedded by every tooler the way a Release embeds domain.Release. +type Tool struct { + Logger *slog.Logger - for _, toolName := range toolNames { - if err := ExecChecker(ctx, toolName); err != nil { - errs = append(errs, err) - } - } + Settings Settings - return errors.Join(errs...) + name string } -func AnyOneExecChecker(ctx context.Context, toolNames ...string) error { - for _, toolName := range toolNames { - if err := ExecChecker(ctx, toolName); err == nil { - return nil - } - } +func NewTool(name string, logger *slog.Logger) Tool { + return Tool{Logger: logger, name: name} +} - return errors.New("none of the tools '" + strings.Join(toolNames, ", ") + "' are available on the system") +func (t Tool) Name() string { + return t.name }