From eb1cb889ab7715bc58f62096dddc1a25ec0cf85c Mon Sep 17 00:00:00 2001 From: Luke Kingland Date: Tue, 14 Apr 2026 10:43:10 +0900 Subject: [PATCH] feat: func clusters - dev cluster creation --- cmd/cluster.go | 293 +++++++++++++ cmd/create.go | 6 +- cmd/root.go | 1 + docs/reference/func_cluster.md | 34 ++ docs/reference/func_cluster_create.md | 63 +++ docs/reference/func_cluster_delete.md | 36 ++ docs/reference/func_cluster_list.md | 22 + pkg/cluster/binaries.go | 184 ++++++++ pkg/cluster/config.go | 130 ++++++ pkg/cluster/create.go | 200 +++++++++ pkg/cluster/delete.go | 48 +++ pkg/cluster/dns.go | 156 +++++++ pkg/cluster/eventing.go | 153 +++++++ pkg/cluster/exec.go | 110 +++++ pkg/cluster/keda.go | 87 ++++ pkg/cluster/kubernetes.go | 174 ++++++++ pkg/cluster/list.go | 31 ++ pkg/cluster/output.go | 159 +++++++ pkg/cluster/registry.go | 599 ++++++++++++++++++++++++++ pkg/cluster/serving.go | 238 ++++++++++ pkg/cluster/tekton.go | 112 +++++ pkg/cluster/versions.go | 42 ++ 22 files changed, 2875 insertions(+), 3 deletions(-) create mode 100644 cmd/cluster.go create mode 100644 docs/reference/func_cluster.md create mode 100644 docs/reference/func_cluster_create.md create mode 100644 docs/reference/func_cluster_delete.md create mode 100644 docs/reference/func_cluster_list.md create mode 100644 pkg/cluster/binaries.go create mode 100644 pkg/cluster/config.go create mode 100644 pkg/cluster/create.go create mode 100644 pkg/cluster/delete.go create mode 100644 pkg/cluster/dns.go create mode 100644 pkg/cluster/eventing.go create mode 100644 pkg/cluster/exec.go create mode 100644 pkg/cluster/keda.go create mode 100644 pkg/cluster/kubernetes.go create mode 100644 pkg/cluster/list.go create mode 100644 pkg/cluster/output.go create mode 100644 pkg/cluster/registry.go create mode 100644 pkg/cluster/serving.go create mode 100644 pkg/cluster/tekton.go create mode 100644 pkg/cluster/versions.go diff --git a/cmd/cluster.go b/cmd/cluster.go new file mode 100644 index 0000000000..678e42d9b8 --- /dev/null +++ b/cmd/cluster.go @@ -0,0 +1,293 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/ory/viper" + "github.com/spf13/cobra" + + "knative.dev/func/pkg/cluster" +) + +// NewClusterCmd creates the parent command for cluster management. +// +// The command is marked Hidden and gated behind the FUNC_ENABLE_CLUSTER +// environment variable: invoking any subcommand without it set returns a +// non-zero exit and a helpful message. The command still appears in +// explicit `func cluster --help`, but not in the top-level listing. +func NewClusterCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cluster", + Aliases: []string{"clusters"}, + Short: "Manage local clusters (experimental)", + Hidden: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if os.Getenv("FUNC_ENABLE_CLUSTER") == "" { + return fmt.Errorf( + "'%s cluster' is an experimental feature and is not enabled by default.\n"+ + "Set FUNC_ENABLE_CLUSTER to a non-empty value to enable it, e.g.:\n"+ + " export FUNC_ENABLE_CLUSTER=1", + cmd.Root().Name()) + } + return nil + }, + Long: ` +NAME + {{rootCmdUse}} cluster - Manage local development clusters (experimental) + +SYNOPSIS + {{rootCmdUse}} cluster [flags] + +DESCRIPTION + Manages local development clusters with Knative, Tekton, and other + components pre-installed, alongside a local container registry for + function image builds. + + This is an experimental feature; set FUNC_ENABLE_CLUSTER to a + non-empty value to enable it. + + See '{{rootCmdUse}} cluster create --help' for the full list of + configurable components and flags. + +EXAMPLES + o Create a default development cluster + $ {{rootCmdUse}} cluster create + + o Create a minimal cluster (just Kubernetes + registry) + $ {{rootCmdUse}} cluster create --serving=false --eventing=false + + o List existing clusters + $ {{rootCmdUse}} cluster list + + o Remove the default cluster + $ {{rootCmdUse}} cluster delete + +`, + } + cmd.AddCommand(NewClusterCreateCmd()) + cmd.AddCommand(NewClusterDeleteCmd()) + cmd.AddCommand(NewClusterListCmd()) + return cmd +} + +// NewClusterCreateCmd creates the 'func cluster create' command. +func NewClusterCreateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a local development cluster", + Long: ` +NAME + {{rootCmdUse}} cluster create - Create a local development cluster + +SYNOPSIS + {{rootCmdUse}} cluster create [-n|--name] [--domain] [--serving] [--eventing] + [--tekton] [--keda] [--container-engine] + [--namespace] [--pac-host] + [--skip-binaries] [--skip-registry-config] [--no-cleanup] + [--retries] + +DESCRIPTION + Creates a local development cluster preconfigured to run Functions, + along with a local container registry and the components needed for + the function runtime (Serving, Eventing, Tekton, etc). + + The generated kubeconfig is written under the func config directory and + does not alter the system's existing kubernetes configuration. To use the + cluster, export the KUBECONFIG path shown on successful creation, e.g.: + + export KUBECONFIG=~/.config/func/clusters/func.local/kubeconfig.yaml + + Installed components are controlled by the --serving, --eventing, + --tekton, and --keda flags. Required binaries are downloaded into the + func config directory on first use unless --skip-binaries is set. + +EXAMPLES + o Create a default development cluster + $ {{rootCmdUse}} cluster create + + o Create a minimal cluster (just Kubernetes + registry) + $ {{rootCmdUse}} cluster create --serving=false --eventing=false + + o Create a cluster with a custom name and domain + $ {{rootCmdUse}} cluster create --name myproject --domain example.local + + o Preserve a failed cluster for inspection + $ {{rootCmdUse}} cluster create --no-cleanup +`, + + PreRunE: bindEnv("name", "retries", "serving", "eventing", "tekton", + "keda", "domain", "container-engine", "namespace", + "pac-host", "skip-binaries", + "skip-registry-config", "no-cleanup"), + RunE: func(cmd *cobra.Command, args []string) error { + cfg := newClusterCreateConfig() + if err := cluster.Create(cmd.Context(), cfg, cmd.OutOrStderr()); err != nil { + return err + } + // Scriptable output: on success stdout receives only the + // kubeconfig path, so callers can do e.g. + // export KUBECONFIG=$(func cluster create) + fmt.Fprintln(cmd.OutOrStdout(), cfg.Kubeconfig()) + return nil + }, + } + + cmd.Flags().StringP("name", "n", "func", + "Cluster name ($FUNC_CLUSTER_NAME)") + cmd.Flags().Int("retries", 1, + "Max cluster allocation attempts ($FUNC_CLUSTER_RETRIES)") + cmd.Flags().Bool("serving", true, + "Install Knative Serving ($FUNC_CLUSTER_SERVING)") + cmd.Flags().Bool("eventing", true, + "Install Knative Eventing ($FUNC_CLUSTER_EVENTING)") + cmd.Flags().Bool("tekton", false, + "Install Tekton + Pipelines-as-Code for in-cluster (remote) builds ($FUNC_CLUSTER_TEKTON)") + cmd.Flags().Bool("keda", false, + "Install KEDA ($FUNC_CLUSTER_KEDA)") + cmd.Flags().String("domain", "localtest.me", + "DNS domain for services ($FUNC_CLUSTER_DOMAIN)") + cmd.Flags().String("container-engine", "", + "Container engine: docker or podman; auto-detected if unset, preferring docker when both are installed ($FUNC_CONTAINER_ENGINE)") + cmd.Flags().String("namespace", "default", + "Kubernetes namespace for RBAC bindings ($FUNC_NAMESPACE)") + cmd.Flags().String("pac-host", "pac-ctr.localtest.me", + "PAC controller hostname ($FUNC_INT_PAC_HOST)") + cmd.Flags().Bool("skip-binaries", false, + "Skip automatic binary downloads ($FUNC_SKIP_BINARIES)") + cmd.Flags().Bool("skip-registry-config", false, + "Skip host registry configuration ($FUNC_SKIP_REGISTRY_CONFIG)") + cmd.Flags().Bool("no-cleanup", false, + "Don't delete cluster on failure ($FUNC_NO_CLEANUP)") + + return cmd +} + +func newClusterCreateConfig() cluster.ClusterConfig { + return cluster.ClusterConfig{ + Name: viper.GetString("name"), + Domain: viper.GetString("domain"), + Serving: viper.GetBool("serving"), + Eventing: viper.GetBool("eventing"), + Tekton: viper.GetBool("tekton"), + Keda: viper.GetBool("keda"), + Retries: viper.GetInt("retries"), + Namespace: viper.GetString("namespace"), + PacHost: viper.GetString("pac-host"), + SkipBinaries: viper.GetBool("skip-binaries"), + SkipRegistryConfig: viper.GetBool("skip-registry-config"), + NoCleanup: viper.GetBool("no-cleanup"), + ContainerEngineOverride: viper.GetString("container-engine"), + KubectlOverride: os.Getenv("FUNC_TEST_KUBECTL"), // override binary path + KindOverride: os.Getenv("FUNC_TEST_KIND"), // override binary path + GitHubActions: os.Getenv("GITHUB_ACTIONS") == "true", // detect CI environments + } +} + +// NewClusterDeleteCmd creates the 'func cluster delete' command. +func NewClusterDeleteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete [name]", + Short: "Delete a local development cluster", + Long: ` +NAME + {{rootCmdUse}} cluster delete - Delete a local development cluster + +SYNOPSIS + {{rootCmdUse}} cluster delete [name] [--container-engine] + [--skip-registry-config] + +DESCRIPTION + Deletes a local development cluster and its associated registry + container. If no name is given, the default cluster "func" is deleted. + + When multiple func-managed clusters exist, specify which one by name. + Use '{{rootCmdUse}} cluster list' to see existing clusters. + + Pass --skip-registry-config to mirror a create invocation that + used the same flag; otherwise delete attempts to remove host + registry-trust entries it never added (harmless but noisy, and may + prompt for sudo). + +EXAMPLES + o Delete the default "func" cluster + $ {{rootCmdUse}} cluster delete + + o Delete a named cluster + $ {{rootCmdUse}} cluster delete myproject + +`, + + PreRunE: bindEnv("container-engine", "skip-registry-config"), + RunE: func(cmd *cobra.Command, args []string) error { + return runClusterDelete(cmd, args) + }, + } + + cmd.Flags().String("container-engine", "", + "Container engine: docker or podman; auto-detected if unset, preferring docker when both are installed ($FUNC_CONTAINER_ENGINE)") + cmd.Flags().Bool("skip-registry-config", false, + "Skip host registry configuration revert ($FUNC_SKIP_REGISTRY_CONFIG)") + + return cmd +} + +func newClusterDeleteConfig() cluster.ClusterConfig { + return cluster.ClusterConfig{ + Name: "func", + ContainerEngineOverride: viper.GetString("container-engine"), + SkipRegistryConfig: viper.GetBool("skip-registry-config"), + KubectlOverride: os.Getenv("FUNC_TEST_KUBECTL"), + KindOverride: os.Getenv("FUNC_TEST_KIND"), + GitHubActions: os.Getenv("GITHUB_ACTIONS") == "true", + } +} + +func runClusterDelete(cmd *cobra.Command, args []string) error { + cfg := newClusterDeleteConfig() + if len(args) > 0 { + cfg.Name = args[0] + } + + clusters := cluster.List() + for _, c := range clusters { + if c == cfg.Name { + return cluster.Delete(cmd.Context(), cfg, cmd.OutOrStderr()) + } + } + + if len(clusters) == 0 { + return fmt.Errorf("no clusters exist; use 'func cluster create' to create one") + } + return fmt.Errorf("cluster %q not found; existing clusters: %v\nUse 'func cluster create' to create one", cfg.Name, clusters) +} + +// NewClusterListCmd creates the 'func cluster list' command. +func NewClusterListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List local development clusters", + Long: ` +NAME + {{rootCmdUse}} cluster list - List local development clusters + +SYNOPSIS + {{rootCmdUse}} cluster list + +DESCRIPTION + Lists local development clusters managed by func. Output is the bare + cluster name, one per line. + +EXAMPLES + o List existing clusters + $ {{rootCmdUse}} cluster list + +`, + RunE: func(cmd *cobra.Command, args []string) error { + for _, name := range cluster.List() { + fmt.Fprintln(cmd.OutOrStdout(), name) + } + return nil + }, + } +} diff --git a/cmd/create.go b/cmd/create.go index 0ef5cbfe9f..b526c0338a 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -295,7 +295,6 @@ func singleCommand(cmd *cobra.Command, args []string, cfg createConfig) string { // pre-client validation should not be required, as the Client does its own // validation. func (c createConfig) Validate(client *fn.Client) (err error) { - // Confirm Name is valid // Note that this is highly constricted, as it must currently adhere to the // naming of a Knative Service, which itself is constrained to a Kubernetes @@ -463,7 +462,8 @@ func (c createConfig) prompt(client *fn.Client) (createConfig, error) { Options: runtimes, Default: surveySelectDefault(c.Runtime, runtimes), }, - }} + }, + } if err := survey.Ask(qs, &c); err != nil { return c, err } @@ -561,7 +561,7 @@ func runCreateHelp(cmd *cobra.Command, args []string, newClient ClientFactory) { options, err := RuntimeTemplateOptions(client) // human-friendly failSoft(err) - var data = struct { + data := struct { Options string Name string }{ diff --git a/cmd/root.go b/cmd/root.go index a34fca3a9f..84028f49de 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -108,6 +108,7 @@ Learn more about Knative at: https://knative.dev`, cfg.Name), common.DefaultWorkDir, newClient, ), + NewClusterCmd(), NewLanguagesCmd(newClient), NewTemplatesCmd(newClient), NewRepositoryCmd(newClient), diff --git a/docs/reference/func_cluster.md b/docs/reference/func_cluster.md new file mode 100644 index 0000000000..0ba0a7a83f --- /dev/null +++ b/docs/reference/func_cluster.md @@ -0,0 +1,34 @@ +## func cluster + +Manage local development clusters + +### Synopsis + +Manage local Kubernetes development clusters with Knative, Tekton, and +other components pre-installed. + +Create a fully configured development cluster: + func cluster create + +Create a minimal cluster (serving only): + func cluster create --eventing=false + +Create a full CI-style cluster: + func cluster create --dapr --keda + +Remove the cluster and associated resources: + func cluster delete + +### Options + +``` + -h, --help help for cluster +``` + +### SEE ALSO + +* [func](func.md) - func manages Knative Functions +* [func cluster create](func_cluster_create.md) - Create a local development cluster +* [func cluster delete](func_cluster_delete.md) - Delete a local development cluster +* [func cluster list](func_cluster_list.md) - List local development clusters + diff --git a/docs/reference/func_cluster_create.md b/docs/reference/func_cluster_create.md new file mode 100644 index 0000000000..d0f486f9e1 --- /dev/null +++ b/docs/reference/func_cluster_create.md @@ -0,0 +1,63 @@ +## func cluster create + +Create a local development cluster + +### Synopsis + +Create a local KinD (Kubernetes in Docker) development cluster configured +for Knative Functions development. The cluster includes a local container +registry and can optionally include Knative Serving, Eventing, Tekton, +KEDA, and Dapr. + +By default, a cluster with Knative Serving and Eventing is created — +suitable for most functions development. Enable Tekton with --tekton if +you also need in-cluster (remote) builds. Additional components +can be enabled with flags for CI/testing workflows. + +EXAMPLES + + # Create a default development cluster + func cluster create + + # Create a cluster with all components (for CI/E2E testing) + func cluster create --dapr --keda + + # Create a minimal cluster (just Kubernetes + registry) + func cluster create --serving=false --eventing=false + + # Create a cluster with a custom name and domain + func cluster create --name myproject --domain example.local + + # Create with retries (useful in CI) + FUNC_CLUSTER_RETRIES=3 func cluster create + +``` +func cluster create +``` + +### Options + +``` + --container-engine string Container engine: docker or podman ($FUNC_CONTAINER_ENGINE) (default "docker") + --dapr Install Dapr runtime + Redis ($FUNC_CLUSTER_DAPR) + --domain string DNS domain for services ($FUNC_CLUSTER_DOMAIN) (default "localtest.me") + --eventing Install Knative Eventing ($FUNC_CLUSTER_EVENTING) (default true) + -h, --help help for create + --keda Install KEDA + HTTP add-on ($FUNC_CLUSTER_KEDA) + -n, --name string Cluster name ($FUNC_CLUSTER_NAME) (default "func") + --namespace string Kubernetes namespace for RBAC bindings ($FUNC_NAMESPACE) (default "default") + --no-cleanup Don't delete cluster on failure ($FUNC_NO_CLEANUP) + --pac-host string PAC controller hostname ($FUNC_INT_PAC_HOST) (default "pac-ctr.localtest.me") + --registry-port int Local registry host port ($FUNC_REGISTRY_PORT) (default 50000) + --retries int Max cluster allocation attempts ($FUNC_CLUSTER_RETRIES) (default 1) + --serving Install Knative Serving ($FUNC_CLUSTER_SERVING) (default true) + --skip-binaries Skip binary downloads ($FUNC_SKIP_BINARIES) + --skip-registry-config Skip host registry configuration ($FUNC_SKIP_REGISTRY_CONFIG) + --tekton Install Tekton + Pipelines-as-Code ($FUNC_CLUSTER_TEKTON) + -v, --verbose Print verbose logs ($FUNC_VERBOSE) +``` + +### SEE ALSO + +* [func cluster](func_cluster.md) - Manage local development clusters + diff --git a/docs/reference/func_cluster_delete.md b/docs/reference/func_cluster_delete.md new file mode 100644 index 0000000000..407c312c2e --- /dev/null +++ b/docs/reference/func_cluster_delete.md @@ -0,0 +1,36 @@ +## func cluster delete + +Delete a local development cluster + +### Synopsis + +Delete a local KinD development cluster and its associated registry container. + +If only one cluster named "func" exists, it is deleted by default. +If multiple func-prefixed clusters exist, specify which one with --name. + +EXAMPLES + + # Delete the default "func" cluster + func cluster delete + + # Delete a named cluster + func cluster delete --name myproject + +``` +func cluster delete [name] +``` + +### Options + +``` + --container-engine string Container engine: docker or podman ($FUNC_CONTAINER_ENGINE) (default "docker") + -h, --help help for delete + -n, --name string Cluster name ($FUNC_CLUSTER_NAME) (default "func") + -v, --verbose Print verbose logs ($FUNC_VERBOSE) +``` + +### SEE ALSO + +* [func cluster](func_cluster.md) - Manage local development clusters + diff --git a/docs/reference/func_cluster_list.md b/docs/reference/func_cluster_list.md new file mode 100644 index 0000000000..1fb3c0cec7 --- /dev/null +++ b/docs/reference/func_cluster_list.md @@ -0,0 +1,22 @@ +## func cluster list + +List local development clusters + +### Synopsis + +List local KinD development clusters. + +``` +func cluster list +``` + +### Options + +``` + -h, --help help for list +``` + +### SEE ALSO + +* [func cluster](func_cluster.md) - Manage local development clusters + diff --git a/pkg/cluster/binaries.go b/pkg/cluster/binaries.go new file mode 100644 index 0000000000..55c9d427b2 --- /dev/null +++ b/pkg/cluster/binaries.go @@ -0,0 +1,184 @@ +package cluster + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +// downloadClient for binary downloads with generous timeout. +var downloadClient = &http.Client{Timeout: 5 * time.Minute} + +// bins lists the binaries to download and manage. Checksums pins a hex +// SHA-256 per "/" key; the installer refuses to run on a +// platform not present in the map. +var bins = []struct { + Name string + Version string + URL func(goos, goarch string) string + Checksums map[string]string +}{ + { + Name: "kubectl", + Version: kubectlVersion, + Checksums: kubectlChecksums, + URL: func(goos, goarch string) string { + return fmt.Sprintf("https://dl.k8s.io/v%s/bin/%s/%s/kubectl", kubectlVersion, goos, goarch) + }, + }, + { + Name: "kind", + Version: kindVersion, + Checksums: kindChecksums, + URL: func(goos, goarch string) string { + return fmt.Sprintf("https://github.com/kubernetes-sigs/kind/releases/download/v%s/kind-%s-%s", kindVersion, goos, goarch) + }, + }, +} + +// ensureBins downloads required tool binaries if they are not already +// present at the correct version. Binaries are stored as - +// with a symlink -> -. Strictly-older versions on +// disk are removed; unparseable or newer entries are left alone. +func ensureBins(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + goos, goarch := runtime.GOOS, runtime.GOARCH + + if goos != "linux" && goos != "darwin" { + return fmt.Errorf("unsupported operating system %q: only linux and darwin are supported", goos) + } + + if err := os.MkdirAll(cfg.BinDir(), 0o755); err != nil { + return fmt.Errorf("creating bin directory: %w", err) + } + + platform := goos + "/" + goarch + for _, bin := range bins { + sum, ok := bin.Checksums[platform] + if !ok { + return fmt.Errorf("no pinned checksum for %s on %s", bin.Name, platform) + } + if err := ensureBin(ctx, cfg.BinDir(), bin.Name, bin.Version, bin.URL(goos, goarch), sum, out); err != nil { + return fmt.Errorf("installing %s: %w", bin.Name, err) + } + } + + fmt.Fprintln(out, green("DONE")) + return nil +} + +// ensureBin installs a single tool at the given version, verifying the +// downloaded bytes against the pinned SHA-256 wantSum. +func ensureBin(ctx context.Context, binDir, name, version, url, wantSum string, out io.Writer) error { + fullName := fmt.Sprintf("%s-%s", name, version) + path := filepath.Join(binDir, fullName) + link := filepath.Join(binDir, name) + + if _, err := os.Stat(path); err == nil { + fmt.Fprintf(out, " %s %s (cached)\n", name, version) + return updateLink(link, fullName) + } else if !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("inspecting cache: %w", err) + } + + fmt.Fprintf(out, " %s %s (downloading)\n", name, version) + if err := download(ctx, url, wantSum, path); err != nil { + return err + } + if err := os.Chmod(path, 0o755); err != nil { + return fmt.Errorf("chmod: %w", err) + } + + removeOldVersions(binDir, name, version) + return updateLink(link, fullName) +} + +// download fetches url to dest atomically: it writes to dest+".tmp" while +// hashing, verifies against the pinned SHA-256 wantSum, then renames the +// tmp into place. A failure anywhere leaves dest untouched. +func download(ctx context.Context, url, wantSum, dest string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := downloadClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + + tmp := dest + ".tmp" + f, err := os.Create(tmp) + if err != nil { + return err + } + h := sha256.New() + if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + + got := hex.EncodeToString(h.Sum(nil)) + if got != strings.ToLower(wantSum) { + os.Remove(tmp) + return fmt.Errorf("checksum mismatch: got %s", got) + } + + return os.Rename(tmp, dest) +} + +// updateLink atomically updates link to point to target. It creates a +// temporary symlink and renames it over link, so a failure leaves the +// previous link intact. +func updateLink(link, target string) error { + tmp := link + ".tmp" + _ = os.Remove(tmp) + if err := os.Symlink(target, tmp); err != nil { + return fmt.Errorf("symlink: %w", err) + } + if err := os.Rename(tmp, link); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("symlink rename: %w", err) + } + return nil +} + +// removeOldVersions removes "-" files whose version parses as +// semver and compares strictly less than current. Non-semver entries, the +// current version, and newer versions are left untouched. +func removeOldVersions(binDir, name, current string) { + matches, _ := filepath.Glob(filepath.Join(binDir, name+"-*")) + cur := "v" + current + if !semver.IsValid(cur) { + return + } + prefix := name + "-" + for _, m := range matches { + v := "v" + strings.TrimPrefix(filepath.Base(m), prefix) + if !semver.IsValid(v) { + continue + } + if semver.Compare(v, cur) < 0 { + os.Remove(m) + } + } +} diff --git a/pkg/cluster/config.go b/pkg/cluster/config.go new file mode 100644 index 0000000000..4e1dc5e541 --- /dev/null +++ b/pkg/cluster/config.go @@ -0,0 +1,130 @@ +package cluster + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + + "knative.dev/func/pkg/config" +) + +// ClusterConfig holds all configuration for cluster create/delete operations. +type ClusterConfig struct { + // Cluster identity + Name string // Cluster name (default: "func") + Domain string // DNS domain for services (default: "localtest.me") + + // Component toggles + Serving bool // Install Knative Serving (default: true) + Eventing bool // Install Knative Eventing (default: true) + Tekton bool // Install Tekton + PAC (default: false) + Keda bool // Install KEDA + HTTP add-on (default: false) + + // Operational + Retries int // Max allocation attempts (default: 1) + Namespace string // K8s namespace for RBAC bindings (default: "default") + + // ContainerEngineOverride, when non-empty, is returned verbatim by + // ContainerEngine(). Empty means "auto-detect". + // The CLI wires --container-engine into this field. + ContainerEngineOverride string + + // PAC + PacHost string // PAC controller hostname (default: "pac-ctr.localtest.me") + + // Skip flags + SkipBinaries bool // Skip binary downloads + SkipRegistryConfig bool // Skip host registry configuration + NoCleanup bool // Don't delete cluster on failure + + // Optional tool path overrides. When non-empty, the kubectl/kind + // accessors return the override verbatim; otherwise they resolve via + // BinDir then PATH. The CLI samples FUNC_TEST_ env vars into + // these fields; the library itself never reads the environment. + KubectlOverride string + KindOverride string + + // CI detection + GitHubActions bool // Auto-detected from GITHUB_ACTIONS env +} + +// BinDir returns the directory where managed tool binaries live: +// /bin. Derived on-demand so callers that mutate Name +// don't need to remember to refresh anything. +func (c ClusterConfig) BinDir() string { + return filepath.Join(config.Dir(), "bin") +} + +// Kubeconfig returns the kubeconfig path for this cluster: +// /clusters/.local/kubeconfig.yaml. +func (c ClusterConfig) Kubeconfig() string { + return filepath.Join(config.Dir(), "clusters", c.Name+".local", "kubeconfig.yaml") +} + +// ContainerEngine returns the container engine to use: the override if +// set, otherwise auto-detected. Called once per engine invocation rather +// than memoized because the auto-detect cost is trivial compared to the +// kind/kubectl work each call precedes. +// +// Auto-detection mirrors hack/common.sh: +// 1. If docker is on PATH, use it — but detect the podman-docker wrapper +// (`/usr/bin/docker` shimmed to exec podman) by inspecting `docker +// --version`; if it reports podman, switch to podman so downstream +// podman-specific workarounds fire. +// 2. Otherwise, use podman if present. +// 3. Otherwise, fall back to "docker" (callers will surface a clearer +// error when they try to exec it). +func (c ClusterConfig) ContainerEngine() string { + if c.ContainerEngineOverride != "" { + return c.ContainerEngineOverride + } + if _, err := exec.LookPath("docker"); err == nil { + out, err := exec.Command("docker", "--version").Output() + if err == nil && bytes.Contains(bytes.ToLower(out), []byte("podman")) { + return "podman" + } + return "docker" + } + if _, err := exec.LookPath("podman"); err == nil { + return "podman" + } + return "docker" +} + +// kubectl returns the resolved path to the kubectl binary. +func (c ClusterConfig) kubectl() string { + if c.KubectlOverride != "" { + return c.KubectlOverride + } + return findTool("kubectl", c.BinDir()) +} + +// kind returns the resolved path to the kind binary. +func (c ClusterConfig) kind() string { + if c.KindOverride != "" { + return c.KindOverride + } + return findTool("kind", c.BinDir()) +} + +// findTool resolves a tool path by checking the managed BinDir first, +// then falling back to the system PATH. Overrides (e.g. from FUNC_TEST_) +// are handled by the ClusterConfig accessors before this is called. +func findTool(name, binDir string) string { + if binDir != "" { + p := filepath.Join(binDir, name) + if info, err := os.Stat(p); err == nil && !info.IsDir() { + return p + } + } + if p, err := exec.LookPath(name); err == nil { + return p + } + return name // fallback: bare name, will fail at execution time with a clear error +} + +// controlPlaneContainer returns the kind control plane container name. +func (c ClusterConfig) controlPlaneContainer() string { + return c.Name + "-control-plane" +} diff --git a/pkg/cluster/create.go b/pkg/cluster/create.go new file mode 100644 index 0000000000..58f1716bb2 --- /dev/null +++ b/pkg/cluster/create.go @@ -0,0 +1,200 @@ +package cluster + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "golang.org/x/sync/errgroup" +) + +// Create sets up a local kind development cluster with the configured components. +// This is the Go equivalent of hack/cluster.sh + hack/binaries.sh + hack/registry.sh. +func Create(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + // Set KUBECONFIG for child processes; restore the caller's value on return. + defer setKubeconfig(cfg.Kubeconfig())() + + // Ensure directory exists for the final kubeconfig + if err := os.MkdirAll(filepath.Dir(cfg.Kubeconfig()), 0o755); err != nil { + return fmt.Errorf("creating kubeconfig directory: %w", err) + } + + // Phase 0: Ensure required binaries + if !cfg.SkipBinaries { + if err := ensureBins(ctx, cfg, out); err != nil { + return fmt.Errorf("binary setup: %w", err) + } + } + + if cfg.Retries > 1 { + return allocateWithRetry(ctx, cfg, out) + } + return allocateCluster(ctx, cfg, out) +} + +// allocationRetryBackoff is the wait between failed allocation attempts. +// Used with --retry for flaky CI environments. +const allocationRetryBackoff = 5 * time.Minute + +func allocateWithRetry(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + statusf(out, "Cluster allocation will retry up to %d time(s)", cfg.Retries) + + for attempt := 1; attempt <= cfg.Retries; attempt++ { + statusf(out, "------------------ Attempt %d ------------------", attempt) + + if err := allocateCluster(ctx, cfg, out); err == nil { + return nil + } + if attempt < cfg.Retries { + fmt.Fprintln(out, yellow("------------------ Sleeping for 5 minutes before retry ------------------")) + if err := wait(ctx, allocationRetryBackoff); err != nil { + return err + } + } + } + + return fmt.Errorf("cluster allocation failed after %d attempt(s)", cfg.Retries) +} + +// allocateCluster runs the actual install pipeline. The named return is +// load-bearing: allocationCleanup runs in a defer and needs to see the +// final err value. +func allocateCluster(ctx context.Context, cfg ClusterConfig, out io.Writer) (err error) { + defer func() { allocationCleanup(ctx, cfg, out, err) }() + + // Phase 1: Sequential prerequisites — Kubernetes and load balancer must be + // up before any components can be installed. + if err := installKubernetes(ctx, cfg, out); err != nil { + return err + } + if err := installLoadBalancer(ctx, cfg, out); err != nil { + return err + } + + // Phase 2: Parallel component installation + status(out, "Beginning Cluster Configuration") + fmt.Fprintln(out, "Tasks will be executed in parallel. Logs will be prefixed:") + if cfg.Serving { + fmt.Fprintln(out, "svr: Serving, DNS and Networking") + } + if cfg.Eventing { + fmt.Fprintln(out, "evt: Eventing and Namespace") + } + fmt.Fprintln(out, "reg: Local Registry") + if cfg.Tekton { + fmt.Fprintln(out, "tkt: Tekton Pipelines") + } + if cfg.Keda { + fmt.Fprintln(out, "keda: Keda") + } + fmt.Fprintln(out) + + g, gctx := errgroup.WithContext(ctx) + + // svr: serving -> dns -> networking (sequential within goroutine) + if cfg.Serving { + g.Go(func() error { + w := newPrefixedWriter(out, "svr ") + defer w.Flush() + if err := installServing(gctx, cfg, w); err != nil { + return fmt.Errorf("serving: %w", err) + } + if err := configureDNS(gctx, cfg, w); err != nil { + return fmt.Errorf("dns: %w", err) + } + return installNetworking(gctx, cfg, w) + }) + } + + // evt: eventing -> namespace + if cfg.Eventing { + g.Go(func() error { + w := newPrefixedWriter(out, "evt ") + defer w.Flush() + if err := installEventing(gctx, cfg, w); err != nil { + return fmt.Errorf("eventing: %w", err) + } + return configureNamespace(gctx, cfg, w) + }) + } + + // reg: registry (always) + g.Go(func() error { + w := newPrefixedWriter(out, "reg ") + defer w.Flush() + return installRegistry(gctx, cfg, w) + }) + + // tkt: tekton -> pac + if cfg.Tekton { + g.Go(func() error { + w := newPrefixedWriter(out, "tkt ") + defer w.Flush() + if err := installTekton(gctx, cfg, w); err != nil { + return fmt.Errorf("tekton: %w", err) + } + return installPAC(gctx, cfg, w) + }) + } + + // keda: keda -> keda_http_addon + if cfg.Keda { + g.Go(func() error { + w := newPrefixedWriter(out, "keda ") + defer w.Flush() + if err := installKeda(gctx, cfg, w); err != nil { + return fmt.Errorf("keda: %w", err) + } + return installKedaHTTPAddon(gctx, cfg, w) + }) + } + + if err := g.Wait(); err != nil { + return err + } + + // Phase 3: Magic DNS (requires all services to be up) + if err := configureMagicDNS(ctx, cfg, out); err != nil { + return err + } + + printNextSteps(cfg, out) + + fmt.Fprintf(out, "\n%s\n\n", green("DONE")) + return nil +} + +// allocationCleanup runs after allocateCluster to report the outcome. On +// success it's a no-op; on failure it prints the error and either leaves +// the partial cluster in place (--no-cleanup) or tears it down. +func allocationCleanup(ctx context.Context, cfg ClusterConfig, out io.Writer, err error) { + if err == nil { + return + } + fmt.Fprintf(out, "%s\n", red(fmt.Sprintf("Allocation failed: %v", err))) + if cfg.NoCleanup { + fmt.Fprintln(out, yellow("Cluster left in place for inspection (--no-cleanup). Clean up with: func cluster delete")) + return + } + // Delete is best-effort: it emits yellow warnings inline and always + // returns nil. The outer allocation error (`err`) is what callers see. + _ = Delete(ctx, cfg, out) + fmt.Fprintln(out) + fmt.Fprintln(out, yellow("To inspect a failed cluster, retry with --no-cleanup:")) + fmt.Fprintf(out, " func cluster create --no-cleanup\n") + fmt.Fprintln(out) +} + +func printNextSteps(cfg ClusterConfig, out io.Writer) { + fmt.Fprintf(out, ` +Next Steps +---------- + +To use the new cluster, set the following environment variable: + + export KUBECONFIG=%[1]s +`, cfg.Kubeconfig()) +} diff --git a/pkg/cluster/delete.go b/pkg/cluster/delete.go new file mode 100644 index 0000000000..f5dffc29b5 --- /dev/null +++ b/pkg/cluster/delete.go @@ -0,0 +1,48 @@ +package cluster + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" +) + +// Delete removes a single func-managed dev cluster. The shared registry +// container and the host's insecure-registries entry are removed only when +// the *last* func-managed cluster is being torn down — other surviving +// clusters keep using the shared registry. +func Delete(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + // Set KUBECONFIG for child processes; restore the caller's value on return. + defer setKubeconfig(cfg.Kubeconfig())() + + status(out, "Deleting Cluster") + + if err := run(ctx, out, "", + cfg.kind(), "delete", "cluster", + "--name="+cfg.Name, + "--kubeconfig="+cfg.Kubeconfig()); err != nil { + warnf(out, "failed to delete cluster %q: %v", cfg.Name, err) + } + + // Remove this cluster's kubeconfig dir so the "last cluster?" check + // below reflects the post-delete state. + _ = os.RemoveAll(filepath.Dir(cfg.Kubeconfig())) + + remaining := List() + if len(remaining) == 0 { + status(out, "Last func cluster removed; tearing down shared registry") + teardownRegistry(ctx, cfg, out) + if !cfg.SkipRegistryConfig { + revertHostRegistry(out) + } + } else { + fmt.Fprintf(out, "Registry left running; shared with %d other func-managed cluster(s): %v\n", + len(remaining), remaining) + } + + fmt.Fprintf(out, "%s Downloaded container images are not automatically removed.\n", red("NOTE:")) + fmt.Fprintln(out, green("DONE")) + + return nil +} diff --git a/pkg/cluster/dns.go b/pkg/cluster/dns.go new file mode 100644 index 0000000000..7a0781df36 --- /dev/null +++ b/pkg/cluster/dns.go @@ -0,0 +1,156 @@ +package cluster + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" +) + +// configureMagicDNS patches CoreDNS to resolve the configured domain (e.g., +// localtest.me) to the cluster node's IP addresses. +func configureMagicDNS(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + start := time.Now() + status(out, "Configuring Magic DNS") + + ipv4, ipv6, err := getKindNodeIPs(ctx, cfg) + if err != nil { + return fmt.Errorf("getting cluster node IPs for DNS: %w", err) + } + + if err := patchCoreDNSConfigMap(ctx, cfg, out, ipv4, ipv6); err != nil { + return err + } + if err := patchCoreDNSDeployment(ctx, cfg, out); err != nil { + return err + } + + // Deployment patch triggers a rolling restart. Sleep so the new pods + // enter NotReady before we wait for Ready — otherwise the old pods + // still satisfy the condition and we return before the restart lands. + if err := wait(ctx, 5*time.Second); err != nil { + return err + } + if err := run(ctx, out, "", + cfg.kubectl(), "wait", "pod", + "--for=condition=Ready", "-l", "!job-name", + "-n", "kube-system", "--timeout=60s"); err != nil { + return fmt.Errorf("waiting for coredns: %w", err) + } + + success(out, "Magic DNS", time.Since(start)) + return nil +} + +// patchCoreDNSConfigMap writes a new Corefile and an example.db zone file +// (containing A/AAAA records for cfg.Domain) into the coredns ConfigMap. +// CoreDNS's `reload` plugin picks the new data up within ~30s. +func patchCoreDNSConfigMap(ctx context.Context, cfg ClusterConfig, out io.Writer, ipv4, ipv6 string) error { + var records strings.Builder + if ipv4 != "" { + fmt.Fprintf(&records, "%s.\tIN\tA\t%s\n*.%s.\tIN\tA\t%s\n", cfg.Domain, ipv4, cfg.Domain, ipv4) + } + if ipv6 != "" { + fmt.Fprintf(&records, "%s.\tIN\tAAAA\t%s\n*.%s.\tIN\tAAAA\t%s\n", cfg.Domain, ipv6, cfg.Domain, ipv6) + } + + corefile := fmt.Sprintf(corefileTemplate, cfg.Domain) + exampleDB := fmt.Sprintf(exampleDBTemplate, cfg.Domain, cfg.Domain, records.String()) + + patch, err := json.Marshal(map[string]any{ + "data": map[string]string{ + "Corefile": corefile, + "example.db": exampleDB, + }, + }) + if err != nil { + return fmt.Errorf("marshaling corefile patch: %w", err) + } + if err := run(ctx, out, string(patch), + cfg.kubectl(), "patch", "cm/coredns", "-n", "kube-system", "--patch-file", "/dev/stdin"); err != nil { + return fmt.Errorf("patching coredns configmap: %w", err) + } + return nil +} + +// patchCoreDNSDeployment applies a strategic-merge patch to mount both the +// Corefile and example.db keys from the coredns ConfigMap. The patch triggers +// a pod restart, which picks up the fresh ConfigMap contents. +func patchCoreDNSDeployment(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + if err := run(ctx, out, corednsDeployPatch, + cfg.kubectl(), "patch", "deploy/coredns", "-n", "kube-system", "--patch-file", "/dev/stdin"); err != nil { + return fmt.Errorf("patching coredns deployment: %w", err) + } + return nil +} + +// corefileTemplate is the CoreDNS Corefile. %s is the magic-DNS domain whose +// zone data lives in example.db (mounted alongside the Corefile by +// corednsDeployPatch). +const corefileTemplate = `.:53 { + errors + health { + lameduck 5s + } + ready + kubernetes cluster.local in-addr.arpa ip6.arpa { + pods insecure + fallthrough in-addr.arpa ip6.arpa + ttl 30 + } + file /etc/coredns/example.db %s + prometheus :9153 + forward . /etc/resolv.conf { + max_concurrent 1000 + } + cache 30 + loop + reload + loadbalance +} +` + +// exampleDBTemplate is a BIND zone file: header comment, SOA, then the A/AAAA +// records. The three %s are domain, domain, records. +const exampleDBTemplate = "; %s test file\n" + + "%s.\tIN\tSOA\tsns.dns.icann.org. noc.dns.icann.org. 2015082541 7200 3600 1209600 3600\n" + + "%s" + +// corednsDeployPatch mounts the Corefile and example.db keys from the +// coredns ConfigMap into /etc/coredns. +const corednsDeployPatch = `{ + "spec": { + "template": { + "spec": { + "$setElementOrder/volumes": [ + { + "name": "config-volume" + } + ], + "volumes": [ + { + "$retainKeys": [ + "configMap", + "name" + ], + "configMap": { + "items": [ + { + "key": "Corefile", + "path": "Corefile" + }, + { + "key": "example.db", + "path": "example.db" + } + ] + }, + "name": "config-volume" + } + ] + } + } + } +}` diff --git a/pkg/cluster/eventing.go b/pkg/cluster/eventing.go new file mode 100644 index 0000000000..aafbec9f39 --- /dev/null +++ b/pkg/cluster/eventing.go @@ -0,0 +1,153 @@ +package cluster + +import ( + "context" + "fmt" + "io" + "time" +) + +// installEventing installs Knative Eventing CRDs, core, in-memory channel, and broker. +func installEventing(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + start := time.Now() + status(out, "Installing Eventing") + fmt.Fprintf(out, "Version: %s\n", eventingVersion) + + baseURL := fmt.Sprintf("https://github.com/knative/eventing/releases/download/knative-%s", eventingVersion) + + // CRDs + if err := run(ctx, out, "", cfg.kubectl(), "apply", "-f", baseURL+"/eventing-crds.yaml"); err != nil { + return fmt.Errorf("applying eventing CRDs: %w", err) + } + + if err := run(ctx, out, "", cfg.kubectl(), "wait", "--for=condition=Established", "--all", "crd", "--timeout=5m"); err != nil { + return fmt.Errorf("waiting for eventing CRDs: %w", err) + } + + // Core + if err := applyURL(ctx, out, cfg, baseURL+"/eventing-core.yaml"); err != nil { + return fmt.Errorf("applying eventing core: %w", err) + } + if err := run(ctx, out, "", + cfg.kubectl(), "wait", "pod", + "--for=condition=Ready", "-l", "!job-name", + "-n", "knative-eventing", "--timeout=5m"); err != nil { + return fmt.Errorf("waiting for eventing core: %w", err) + } + + // In-memory channel + if err := applyURL(ctx, out, cfg, baseURL+"/in-memory-channel.yaml"); err != nil { + return fmt.Errorf("applying in-memory channel: %w", err) + } + if err := run(ctx, out, "", + cfg.kubectl(), "wait", "pod", + "--for=condition=Ready", "-l", "!job-name", + "-n", "knative-eventing", "--timeout=5m"); err != nil { + return fmt.Errorf("waiting for in-memory channel: %w", err) + } + + // MT channel broker + if err := applyURL(ctx, out, cfg, baseURL+"/mt-channel-broker.yaml"); err != nil { + return fmt.Errorf("applying mt-channel-broker: %w", err) + } + if err := run(ctx, out, "", + cfg.kubectl(), "wait", "pod", + "--for=condition=Ready", "-l", "!job-name", + "-n", "knative-eventing", "--timeout=5m"); err != nil { + return fmt.Errorf("waiting for mt-channel-broker: %w", err) + } + + // Broker ingress + fmt.Fprintf(out, "Exposing broker at broker.%s\n", cfg.Domain) + brokerIngress := fmt.Sprintf(`apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: broker-ingress + namespace: knative-eventing +spec: + ingressClassName: contour-external + rules: + - host: broker.%s + http: + paths: + - backend: + service: + name: broker-ingress + port: + number: 80 + pathType: Prefix + path: / +`, cfg.Domain) + + if err := applyManifest(ctx, out, cfg, brokerIngress); err != nil { + return fmt.Errorf("applying broker ingress: %w", err) + } + + success(out, "Eventing", time.Since(start)) + return nil +} + +// configureNamespace creates the "func" namespace with a default broker and channel config. +func configureNamespace(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + start := time.Now() + status(out, `Configuring Namespace "func"`) + + // Create Namespace (apply for idempotency) + namespace := `apiVersion: v1 +kind: Namespace +metadata: + name: func +` + if err := applyManifest(ctx, out, cfg, namespace); err != nil { + return fmt.Errorf("creating func namespace: %w", err) + } + + // Default Broker + broker := `apiVersion: eventing.knative.dev/v1 +kind: Broker +metadata: + name: func-broker + namespace: func +` + if err := applyManifest(ctx, out, cfg, broker); err != nil { + return fmt.Errorf("applying func broker: %w", err) + } + + // Default Channel + channel := `apiVersion: v1 +kind: ConfigMap +metadata: + name: imc-channel + namespace: knative-eventing +data: + channelTemplateSpec: | + apiVersion: messaging.knative.dev/v1 + kind: InMemoryChannel +` + if err := applyManifest(ctx, out, cfg, channel); err != nil { + return fmt.Errorf("applying imc-channel: %w", err) + } + + // Connect Default Broker->Channel + brokerDefaults := `apiVersion: v1 +kind: ConfigMap +metadata: + name: config-br-defaults + namespace: knative-eventing +data: + default-br-config: | + # This is the cluster-wide default broker channel. + clusterDefault: + brokerClass: MTChannelBasedBroker + apiVersion: v1 + kind: ConfigMap + name: imc-channel + namespace: knative-eventing +` + if err := applyManifest(ctx, out, cfg, brokerDefaults); err != nil { + return fmt.Errorf("applying broker defaults: %w", err) + } + + success(out, "Namespace", time.Since(start)) + return nil +} diff --git a/pkg/cluster/exec.go b/pkg/cluster/exec.go new file mode 100644 index 0000000000..0f4419dfa7 --- /dev/null +++ b/pkg/cluster/exec.go @@ -0,0 +1,110 @@ +package cluster + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// setKubeconfig sets the KUBECONFIG environment variable and returns a +// function that restores the previous value. Every child process spawned +// via `run` inherits the parent env, so all kubectl/kind calls pick this +// up without further plumbing. +func setKubeconfig(path string) (restore func()) { + prev, hadPrev := os.LookupEnv("KUBECONFIG") + os.Setenv("KUBECONFIG", path) + return func() { + if hadPrev { + os.Setenv("KUBECONFIG", prev) + } else { + os.Unsetenv("KUBECONFIG") + } + } +} + +// run executes a command, optionally piping stdin. An empty stdin leaves +// the child's stdin unattached. The child inherits the parent process +// environment — notably KUBECONFIG, which Create/Delete set via +// setKubeconfig with deferred restore. +func run(ctx context.Context, out io.Writer, stdin string, name string, args ...string) error { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdout = out + cmd.Stderr = out + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s %s: %w", filepath.Base(name), strings.Join(args, " "), err) + } + return nil +} + +// runOutput executes a command and returns its stdout as a trimmed string. +func runOutput(ctx context.Context, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("%s %s: %w", filepath.Base(name), strings.Join(args, " "), err) + } + return strings.TrimSpace(string(out)), nil +} + +// applyManifest pipes a YAML manifest string to kubectl apply -f -. +func applyManifest(ctx context.Context, out io.Writer, cfg ClusterConfig, manifest string) error { + return run(ctx, out, manifest, cfg.kubectl(), "apply", "-f", "-") +} + +// applyURL downloads a YAML document from the given URL and applies it via kubectl. +func applyURL(ctx context.Context, out io.Writer, cfg ClusterConfig, url string) error { + body, err := httpGet(ctx, url) + if err != nil { + return fmt.Errorf("downloading %s: %w", url, err) + } + return run(ctx, out, body, cfg.kubectl(), "apply", "-f", "-") +} + +// applyURLServerSide downloads YAML and applies it with --server-side. +func applyURLServerSide(ctx context.Context, out io.Writer, cfg ClusterConfig, url string) error { + body, err := httpGet(ctx, url) + if err != nil { + return fmt.Errorf("downloading %s: %w", url, err) + } + return run(ctx, out, body, cfg.kubectl(), "apply", "--server-side", "-f", "-") +} + +// httpGet fetches a URL and returns the response body as a string. +func httpGet(ctx context.Context, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + b, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(b), nil +} + +// wait sleeps for the given duration, respecting context cancellation. +func wait(ctx context.Context, d time.Duration) error { + select { + case <-time.After(d): + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/pkg/cluster/keda.go b/pkg/cluster/keda.go new file mode 100644 index 0000000000..904cbed3a5 --- /dev/null +++ b/pkg/cluster/keda.go @@ -0,0 +1,87 @@ +package cluster + +import ( + "context" + "fmt" + "io" + "strings" + "time" +) + +// installKeda installs KEDA core components. +func installKeda(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + start := time.Now() + status(out, "Installing Keda") + fmt.Fprintf(out, "Version: %s\n", kedaVersion) + + kedaVersionNum := strings.TrimPrefix(kedaVersion, "v") + + kedaURL := fmt.Sprintf("https://github.com/kedacore/keda/releases/download/%s/keda-%s.yaml", kedaVersion, kedaVersionNum) + if err := applyURLServerSide(ctx, out, cfg, kedaURL); err != nil { + return fmt.Errorf("applying keda: %w", err) + } + + kedaCoreURL := fmt.Sprintf("https://github.com/kedacore/keda/releases/download/%s/keda-%s-core.yaml", kedaVersion, kedaVersionNum) + if err := applyURLServerSide(ctx, out, cfg, kedaCoreURL); err != nil { + return fmt.Errorf("applying keda core: %w", err) + } + + fmt.Fprintln(out, "Waiting for Keda to become ready") + if err := run(ctx, out, "", + cfg.kubectl(), "wait", "deployment", + "--all", "--timeout=-1s", + "--for=condition=Available", "--namespace", "keda"); err != nil { + return fmt.Errorf("waiting for keda: %w", err) + } + + _ = run(ctx, out, "", cfg.kubectl(), "get", "pod", "-n", "keda") + success(out, "Keda", time.Since(start)) + return nil +} + +// installKedaHTTPAddon installs the KEDA HTTP add-on. +func installKedaHTTPAddon(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + start := time.Now() + status(out, "Installing Keda HTTP add-on") + fmt.Fprintf(out, "Version: %s\n", kedaHTTPAddOnVersion) + + addonVersionNum := strings.TrimPrefix(kedaHTTPAddOnVersion, "v") + + crdsURL := fmt.Sprintf("https://github.com/kedacore/http-add-on/releases/download/%s/keda-add-ons-http-%s-crds.yaml", + kedaHTTPAddOnVersion, addonVersionNum) + if err := applyURLServerSide(ctx, out, cfg, crdsURL); err != nil { + return fmt.Errorf("applying keda HTTP add-on CRDs: %w", err) + } + + addonURL := fmt.Sprintf("https://github.com/kedacore/http-add-on/releases/download/%s/keda-add-ons-http-%s.yaml", + kedaHTTPAddOnVersion, addonVersionNum) + if err := applyURLServerSide(ctx, out, cfg, addonURL); err != nil { + return fmt.Errorf("applying keda HTTP add-on: %w", err) + } + + fmt.Fprintln(out, "Waiting for Keda HTTP add-on to become ready") + if err := run(ctx, out, "", + cfg.kubectl(), "wait", "deployment", + "--all", "--timeout=-1s", + "--for=condition=Available", "--namespace", "keda"); err != nil { + return fmt.Errorf("waiting for keda HTTP add-on: %w", err) + } + + // Reduce resource requests for CI environments + if cfg.GitHubActions { + status(out, "Reducing KEDA HTTP add-on resource requests for CI") + + _ = run(ctx, out, "", + cfg.kubectl(), "scale", "deployment", "keda-add-ons-http-interceptor", + "-n", "keda", "--replicas=1") + _ = run(ctx, out, "", + cfg.kubectl(), "scale", "deployment", "keda-add-ons-http-scaler", + "-n", "keda", "--replicas=1") + + fmt.Fprintln(out, green("✅ Resource requests reduced for CI")) + } + + _ = run(ctx, out, "", cfg.kubectl(), "get", "pod", "-n", "keda") + success(out, "Keda HTTP add-on", time.Since(start)) + return nil +} diff --git a/pkg/cluster/kubernetes.go b/pkg/cluster/kubernetes.go new file mode 100644 index 0000000000..4e11b4e4be --- /dev/null +++ b/pkg/cluster/kubernetes.go @@ -0,0 +1,174 @@ +package cluster + +import ( + "context" + "encoding/json" + "fmt" + "io" + "time" +) + +// kindConfigTemplate is the kind cluster config. +// +// metalLBPoolTemplate is the MetalLB IPAddressPool + L2Advertisement. +// %s is the pre-formatted YAML list of "/" lines. +const kindConfigTemplate = `kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: + - role: control-plane + image: kindest/node:%[1]s + extraPortMappings: + - containerPort: 80 + hostPort: 80 + listenAddress: "127.0.0.1" + - containerPort: 443 + hostPort: 443 + listenAddress: "127.0.0.1" + - containerPort: 30022 + hostPort: 30022 + listenAddress: "127.0.0.1" +containerdConfigPatches: +- |- + [plugins."io.containerd.grpc.v1.cri".registry.mirrors."localhost:%[2]d"] + endpoint = ["http://%[3]s:%[4]d"] + [plugins."io.containerd.grpc.v1.cri".registry.mirrors."registry.default.svc.cluster.local:%[4]d"] + endpoint = ["http://%[3]s:%[4]d"] + [plugins."io.containerd.grpc.v1.cri".registry.mirrors."ghcr.io"] + endpoint = ["http://%[3]s:%[4]d"] + [plugins."io.containerd.grpc.v1.cri".registry.mirrors."quay.io"] + endpoint = ["http://%[3]s:%[4]d"] +` + +const metalLBPoolTemplate = `apiVersion: metallb.io/v1beta1 +kind: IPAddressPool +metadata: + name: example + namespace: metallb-system +spec: + addresses: +%s--- +apiVersion: metallb.io/v1beta1 +kind: L2Advertisement +metadata: + name: empty + namespace: metallb-system +` + +// installKubernetes creates a kind cluster with the configured node image and port mappings. +func installKubernetes(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + start := time.Now() + status(out, "Allocating") + + kindConfig := fmt.Sprintf(kindConfigTemplate, + kindNodeVersion, registryHostPort, registryContainerName, registryContainerPort) + + err := run(ctx, out, kindConfig, + cfg.kind(), "create", "cluster", + "--name="+cfg.Name, + "--kubeconfig="+cfg.Kubeconfig(), + "--wait=60s", + "--config=-") + if err != nil { + return fmt.Errorf("creating cluster: %w", err) + } + + err = run(ctx, out, "", + cfg.kubectl(), "wait", "pod", + "--for=condition=Ready", "-l", "!job-name", "-n", "kube-system", "--timeout=5m") + if err != nil { + return fmt.Errorf("waiting for kube-system: %w", err) + } + + success(out, "Kubernetes", time.Since(start)) + return nil +} + +// installLoadBalancer installs MetalLB and configures the address pool using +// the kind node's IP addresses (parsed natively in Go, replacing jq). +func installLoadBalancer(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + start := time.Now() + status(out, "Installing Load Balancer (MetalLB)") + + url := fmt.Sprintf("https://raw.githubusercontent.com/metallb/metallb/%s/config/manifests/metallb-native.yaml", metalLBVersion) + err := run(ctx, out, "", + cfg.kubectl(), "apply", "-f", url) + if err != nil { + return fmt.Errorf("applying metallb: %w", err) + } + + err = run(ctx, out, "", + cfg.kubectl(), "wait", + "--namespace", "metallb-system", + "--for=condition=ready", "pod", + "--selector=app=metallb", + "--timeout=300s") + if err != nil { + return fmt.Errorf("waiting for metallb: %w", err) + } + + ipv4, ipv6, err := getKindNodeIPs(ctx, cfg) + if err != nil { + return fmt.Errorf("getting cluster node IPs: %w", err) + } + + var addresses []string + if ipv4 != "" { + addresses = append(addresses, ipv4+"/32") + } + if ipv6 != "" { + addresses = append(addresses, ipv6+"/128") + } + if len(addresses) == 0 { + return fmt.Errorf("could not determine cluster node IP addresses") + } + + var addrYAML string + for _, addr := range addresses { + addrYAML += fmt.Sprintf(" - %s\n", addr) + } + + fmt.Fprintln(out, "Setting up address pool.") + manifest := fmt.Sprintf(metalLBPoolTemplate, addrYAML) + + if err := applyManifest(ctx, out, cfg, manifest); err != nil { + return fmt.Errorf("configuring metallb address pool: %w", err) + } + + success(out, "Loadbalancer", time.Since(start)) + return nil +} + +// getKindNodeIPs inspects the kind control-plane container to extract its +// IPv4 and IPv6 addresses on the "kind" network. +func getKindNodeIPs(ctx context.Context, cfg ClusterConfig) (ipv4, ipv6 string, err error) { + output, err := runOutput(ctx, cfg.ContainerEngine(), "container", "inspect", cfg.controlPlaneContainer()) + if err != nil { + return "", "", err + } + + var results []containerInspectResult + if err := json.Unmarshal([]byte(output), &results); err != nil { + return "", "", fmt.Errorf("parsing container inspect output: %w", err) + } + + if len(results) == 0 { + return "", "", fmt.Errorf("container %s not found", cfg.controlPlaneContainer()) + } + + kindNet, ok := results[0].NetworkSettings.Networks["kind"] + if !ok { + return "", "", fmt.Errorf("network 'kind' not found on container %s", cfg.controlPlaneContainer()) + } + + return kindNet.IPAddress, kindNet.GlobalIPv6Address, nil +} + +// containerInspectResult models the relevant fields from docker/podman inspect output. +type containerInspectResult struct { + NetworkSettings struct { + Networks map[string]struct { + IPAddress string `json:"IPAddress"` + GlobalIPv6Address string `json:"GlobalIPv6Address"` + } `json:"Networks"` + } `json:"NetworkSettings"` +} diff --git a/pkg/cluster/list.go b/pkg/cluster/list.go new file mode 100644 index 0000000000..641d449078 --- /dev/null +++ b/pkg/cluster/list.go @@ -0,0 +1,31 @@ +package cluster + +import ( + "path/filepath" + "strings" + + "knative.dev/func/pkg/config" +) + +// List returns the names of func-managed clusters on this host — those +// with a kubeconfig under /clusters/.local/. Kind +// clusters created outside func (e.g. via `kind create cluster` directly) +// are not included; this is intentional so that `func cluster delete foo` +// can't accidentally remove an unrelated kind cluster, and so the list +// reflects only what this tool manages. +// +// No error return: a missing/unreadable clusters directory means "none", +// which is the correct answer for a fresh system. +func List() []string { + matches, err := filepath.Glob(filepath.Join(config.Dir(), "clusters", "*.local", "kubeconfig.yaml")) + if err != nil { + return nil + } + var names []string + for _, m := range matches { + // m = .../clusters/.local/kubeconfig.yaml + dir := filepath.Base(filepath.Dir(m)) + names = append(names, strings.TrimSuffix(dir, ".local")) + } + return names +} diff --git a/pkg/cluster/output.go b/pkg/cluster/output.go new file mode 100644 index 0000000000..67f890a614 --- /dev/null +++ b/pkg/cluster/output.go @@ -0,0 +1,159 @@ +package cluster + +import ( + "bytes" + "fmt" + "io" + "os" + "sync" + "time" + + "golang.org/x/term" +) + +// ANSI color codes matching the shell scripts' tput-based colors. +var ( + colorEnabled bool + ansiRed = "\033[1;31m" + ansiGreen = "\033[1;32m" + ansiBlue = "\033[1;34m" + ansiYellow = "\033[1;33m" + ansiGrey = "\033[1;90m" + ansiReset = "\033[0m" +) + +func init() { + // Honor the NO_COLOR convention (https://no-color.org): any non-empty + // value disables ANSI output. Otherwise gate on stderr being a TTY — + // all chatty/status output from this package goes there (stdout is + // reserved for the machine-readable kubeconfig path), so the stderr + // fd is what we actually need to check. + colorEnabled = os.Getenv("NO_COLOR") == "" && term.IsTerminal(int(os.Stderr.Fd())) +} + +func red(s string) string { + if !colorEnabled { + return s + } + return ansiRed + s + ansiReset +} + +func green(s string) string { + if !colorEnabled { + return s + } + return ansiGreen + s + ansiReset +} + +func blue(s string) string { + if !colorEnabled { + return s + } + return ansiBlue + s + ansiReset +} + +func yellow(s string) string { + if !colorEnabled { + return s + } + return ansiYellow + s + ansiReset +} + +func grey(s string) string { + if !colorEnabled { + return s + } + return ansiGrey + s + ansiReset +} + +// prefixedWriter wraps an io.Writer, prepending a prefix to every line. +// It is safe for concurrent use from multiple goroutines. +type prefixedWriter struct { + mu sync.Mutex + out io.Writer + prefix []byte + buf []byte +} + +// newPrefixedWriter creates a writer that prepends prefix to every line written. +func newPrefixedWriter(out io.Writer, prefix string) *prefixedWriter { + return &prefixedWriter{ + out: out, + prefix: []byte(prefix), + } +} + +func (pw *prefixedWriter) Write(p []byte) (n int, err error) { + pw.mu.Lock() + defer pw.mu.Unlock() + + pw.buf = append(pw.buf, p...) + + for { + idx := bytes.IndexByte(pw.buf, '\n') + if idx < 0 { + break + } + line := pw.buf[:idx+1] + pw.buf = pw.buf[idx+1:] + + if _, err = pw.out.Write(pw.prefix); err != nil { + return len(p), err + } + if _, err = pw.out.Write(line); err != nil { + return len(p), err + } + } + return len(p), nil +} + +// Flush writes any remaining partial line. +func (pw *prefixedWriter) Flush() error { + pw.mu.Lock() + defer pw.mu.Unlock() + if len(pw.buf) > 0 { + if _, err := pw.out.Write(pw.prefix); err != nil { + return err + } + if _, err := pw.out.Write(pw.buf); err != nil { + return err + } + if _, err := pw.out.Write([]byte{'\n'}); err != nil { + return err + } + pw.buf = nil + } + return nil +} + +// status prints a blue status line (fixed message). +func status(out io.Writer, msg string) { + fmt.Fprintln(out, blue(msg)) +} + +// statusf prints a blue status line with printf-style formatting. +func statusf(out io.Writer, format string, args ...any) { + fmt.Fprintln(out, blue(fmt.Sprintf(format, args...))) +} + +// warnf prints a yellow "Warning:" line with printf-style formatting. +func warnf(out io.Writer, format string, args ...any) { + fmt.Fprintln(out, yellow("Warning: "+fmt.Sprintf(format, args...))) +} + +// success prints a green checkmark status line with duration. +func success(out io.Writer, component string, d time.Duration) { + fmt.Fprintf(out, "%s %s\n", green("✅ "+component), grey(formatDuration(d))) +} + +// formatDuration returns a human-friendly duration string. +func formatDuration(d time.Duration) string { + if d < time.Second { + return fmt.Sprintf("(%dms)", d.Milliseconds()) + } + s := int(d.Seconds()) + if s < 60 { + return fmt.Sprintf("(%ds)", s) + } + return fmt.Sprintf("(%dm%02ds)", s/60, s%60) +} diff --git a/pkg/cluster/registry.go b/pkg/cluster/registry.go new file mode 100644 index 0000000000..eaa87f6db2 --- /dev/null +++ b/pkg/cluster/registry.go @@ -0,0 +1,599 @@ +package cluster + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +const ( + // registryContainerName is the fixed name of the shared local registry + // container. All func-managed dev clusters on the host share this + // single registry. + registryContainerName = "func-registry" + // registryHostPort is the TCP port the registry is published on to the + // host; it also appears in the host container engine's + // insecure-registries list. + registryHostPort = 50000 + // registryContainerPort is the port the `registry:2` image listens on + // inside the container. + registryContainerPort = 5000 +) + +// registryAddr is the host-side address used in daemon.json / +// registries.conf and in the in-cluster `local-registry-hosting` ConfigMap. +// Derived from registryHostPort so the two can't drift apart. +var registryAddr = fmt.Sprintf("localhost:%d", registryHostPort) + +// installRegistry starts the shared local container registry, configures +// host-side trust for it, and applies the in-cluster ConfigMap + Service +// the kind cluster uses to reach it. +func installRegistry(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + start := time.Now() + status(out, "Creating Registry") + + if err := ensureRegistry(ctx, cfg, out); err != nil { + return err + } + + if !cfg.SkipRegistryConfig { + if err := configureHostRegistry(out); err != nil { + return err + } + } + + // ConfigMap for local registry hosting + registryConfigMap := fmt.Sprintf(`apiVersion: v1 +kind: ConfigMap +metadata: + name: local-registry-hosting + namespace: kube-public +data: + localRegistryHosting.v1: | + host: "localhost:%d" + help: "https://kind.sigs.k8s.io/docs/user/local-registry/" +`, registryHostPort) + + if err := applyManifest(ctx, out, cfg, registryConfigMap); err != nil { + return fmt.Errorf("applying registry configmap: %w", err) + } + + // ExternalName service for in-cluster access + registrySvc := fmt.Sprintf(`apiVersion: v1 +kind: Service +metadata: + name: registry + namespace: default +spec: + type: ExternalName + externalName: %s +`, registryContainerName) + + if err := applyManifest(ctx, out, cfg, registrySvc); err != nil { + return fmt.Errorf("applying registry service: %w", err) + } + + success(out, "Registry", time.Since(start)) + return nil +} + +// ensureRegistry makes sure the shared func-registry container exists, is +// running, and is attached to the `kind` docker network. Idempotent; safe +// to call whether or not another func-managed cluster has already +// provisioned it. +// +// Scope is intentionally just the container lifecycle — host-side trust +// config is the orchestrator's responsibility (see installRegistry). +// TODO: should we rename the kind network to "kind-func" to avoid collision +// with a developer's own kind usage? +func ensureRegistry(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + exists, running, networked, err := registryStatus(ctx, cfg) + if err != nil { + return err + } + if !exists { + portMap := fmt.Sprintf("127.0.0.1:%d:%d", registryHostPort, registryContainerPort) + // --net=kind attaches at creation time, so no separate network + // connect is needed on this path. + return run(ctx, out, "", + cfg.ContainerEngine(), "run", + "-d", + "--restart=always", + "-p", portMap, + "--net=kind", + "--name", registryContainerName, + "registry:2") + } + if !running { + if err := run(ctx, out, "", cfg.ContainerEngine(), "start", registryContainerName); err != nil { + return fmt.Errorf("starting registry: %w", err) + } + } + if !networked { + if err := run(ctx, out, "", cfg.ContainerEngine(), "network", "connect", "kind", registryContainerName); err != nil { + return fmt.Errorf("connecting registry to kind network: %w", err) + } + } + return nil +} + +// registryStatus inspects the shared registry container. A non-nil err +// means the engine itself errored in a way that isn't "no such object"; +// callers should surface it. A (false, false, false, nil) return means +// "container is absent" or the inspect was unparseable — either way, +// treated as fresh state. +func registryStatus(ctx context.Context, cfg ClusterConfig) (exists, running, networked bool, err error) { + output, inspectErr := runOutput(ctx, cfg.ContainerEngine(), "container", "inspect", registryContainerName) + if inspectErr != nil { + // `container inspect ` exits non-zero; so does any real + // engine failure. Treat both as "not present" — a real failure + // resurfaces on the next engine command. + return false, false, false, nil + } + var results []struct { + State struct { + Running bool `json:"Running"` + } `json:"State"` + NetworkSettings struct { + Networks map[string]json.RawMessage `json:"Networks"` + } `json:"NetworkSettings"` + } + if err := json.Unmarshal([]byte(output), &results); err != nil { + return false, false, false, fmt.Errorf("parsing inspect output: %w", err) + } + if len(results) == 0 { + return false, false, false, nil + } + exists = true + running = results[0].State.Running + _, networked = results[0].NetworkSettings.Networks["kind"] + return +} + +// configureHostRegistry configures the host's container engine(s) to +// trust the shared local registry. Mirror of revertHostRegistry; called +// at most once per installRegistry (the caller gates on +// SkipRegistryConfig). Equivalent to hack/registry.sh. +func configureHostRegistry(out io.Writer) error { + status(out, "Enabling local HTTP access to container registry") + + warnNix(out) + + anyConfigured := false + if hasCommand("docker") { + if err := configureDockerHTTP(out); err != nil { + warnf(out, "Failed to configure Docker: %v", err) + } else { + anyConfigured = true + } + } + + if hasCommand("podman") { + if err := configurePodmanHTTP(out); err != nil { + warnf(out, "Failed to configure Podman: %v", err) + } else { + anyConfigured = true + } + } + + if anyConfigured { + fmt.Fprintln(out, yellow(fmt.Sprintf( + "Note: %s is now a trusted insecure registry for this machine's container\n"+ + " engine. Any process with local access can push, pull, or delete\n"+ + " images there. Removed when the last func-managed cluster is\n"+ + " deleted.", + registryAddr))) + } + return nil +} + +// configureDockerHTTP adds the registry to Docker's insecure-registries +// list, preserving any other daemon.json settings the user has configured. +func configureDockerHTTP(out io.Writer) error { + path, useSudo := dockerConfigPath() + config, err := readDockerDaemon(path, useSudo) + if err != nil { + return err + } + if err := addInsecureRegistry(config, registryAddr); err != nil { + return err + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("marshaling daemon.json: %w", err) + } + if err := writeFileWithSudo(path, data, useSudo); err != nil { + return fmt.Errorf("writing daemon.json: %w", err) + } + + fmt.Fprintf(out, "OK %s\n", path) + if runtime.GOOS == "darwin" { + fmt.Fprintln(out, yellow("*** If Docker Desktop is running, please restart it via the menu bar icon ***")) + } else { + fmt.Fprintln(out, "daemon.json updated; not restarting dockerd mid-setup (would tear down the in-progress cluster)") + } + return nil +} + +// addInsecureRegistry appends registry to config["insecure-registries"] if +// not already present, preserving any existing entries. Errors if the +// existing value isn't a JSON array, rather than silently overwriting. +func addInsecureRegistry(config map[string]any, registry string) error { + raw, present := config["insecure-registries"] + if !present { + config["insecure-registries"] = []any{registry} + return nil + } + existing, ok := raw.([]any) + if !ok { + return fmt.Errorf("unexpected type for insecure-registries: %T (refusing to overwrite)", raw) + } + for _, r := range existing { + if s, ok := r.(string); ok && s == registry { + return nil + } + } + config["insecure-registries"] = append(existing, registry) + return nil +} + +// configurePodmanHTTP adds the registry to Podman's registries.conf. +func configurePodmanHTTP(out io.Writer) error { + configFile, useSudo, exists := podmanConfigPath() + + if !exists { + // Neither user nor system config present — create a fresh user-level file. + userConfigDir := filepath.Dir(configFile) + fmt.Fprintln(out, "No existing Podman registries.conf found.") + if err := os.MkdirAll(userConfigDir, 0o755); err != nil { + fmt.Fprintln(out, "Could not create user config directory. Skipping Podman registry configuration.") + return nil + } + fmt.Fprintf(out, "Creating new user-level Podman registry config at %s\n", configFile) + content := fmt.Sprintf("# Podman registries configuration\n# Generated by func cluster create\n\n[[registry]]\nlocation = %q\ninsecure = true\n", registryAddr) + if err := os.WriteFile(configFile, []byte(content), 0o644); err != nil { + return fmt.Errorf("writing config: %w", err) + } + fmt.Fprintf(out, "Successfully created Podman registry configuration for %s\n", registryAddr) + setupPodmanMacOSForwarding(out) + return nil + } + + if useSudo { + fmt.Fprintf(out, "Using existing system Podman registry config at %s\n", configFile) + } else { + fmt.Fprintf(out, "Using existing user Podman registry config at %s\n", configFile) + } + + // Read existing config + data, err := readFileWithSudo(configFile, useSudo) + if err != nil { + return fmt.Errorf("reading %s: %w", configFile, err) + } + content := string(data) + + // Check if already configured + if strings.Contains(content, registryAddr) { + fmt.Fprintf(out, "%s is already configured in %s\n", registryAddr, configFile) + return nil + } + + // Only v2 (`[[registry]]` stanzas) is handled. v1 + // (`[registries.insecure]`) is deprecated and its in-place edit + // paths are error-prone, so we skip rather than risk clobbering. + if !strings.Contains(content, "[[registry]]") && strings.Contains(content, "[registries.insecure]") { + warnf(out, "%s appears to use the deprecated v1 registries.conf format.\n"+ + " Skipping Podman config; add %q manually to continue.", + configFile, registryAddr) + return nil + } + + fmt.Fprintln(out, "Adding insecure registry") + appendContent := fmt.Sprintf("\n[[registry]]\nlocation = %q\ninsecure = true\n", registryAddr) + if err := appendFileWithSudo(configFile, []byte(appendContent), useSudo); err != nil { + return err + } + + setupPodmanMacOSForwarding(out) + return nil +} + +// setupPodmanMacOSForwarding sets up SSH port forwarding on macOS so the +// Podman VM can access the host's local registry. Idempotent: detects an +// existing backgrounded ssh forwarder and skips rather than spawning +// another (which would leak or fail to bind). +func setupPodmanMacOSForwarding(out io.Writer) { + if runtime.GOOS != "darwin" { + return + } + forward := fmt.Sprintf("-L %d:localhost:%d", registryHostPort, registryHostPort) + if err := exec.Command("pgrep", "-f", forward).Run(); err == nil { + fmt.Fprintln(out, "Podman VM port forwarding already active; skipping") + return + } + fmt.Fprintln(out, "Setting up port forwarding for Podman VM to access registry...") + port := fmt.Sprintf("%d", registryHostPort) + cmd := exec.Command("podman", "machine", "ssh", "--", + "-L", port+":localhost:"+port, "-N", "-f") + cmd.Stdout = out + cmd.Stderr = out + if err := cmd.Run(); err != nil { + fmt.Fprintf(out, "Warning: port forwarding setup failed: %v\n", err) + } +} + +// warnNix detects Nix and emits configuration guidance. +func warnNix(out io.Writer) { + if !hasCommand("nix") && !hasCommand("nixos-rebuild") { + return + } + + fmt.Fprintln(out, yellow("Warning: Nix detected")) + + if hasCommand("docker") { + if runtime.GOOS == "darwin" { + fmt.Fprintf(out, `If Docker Desktop was installed via Nix on macOS, you may need to manually configure the insecure registry. +Please confirm %q is specified as an insecure registry in the docker config file. +`, registryAddr) + } else { + fmt.Fprintf(out, `If Docker was configured using nix, this command will fail to find daemon.json. +Please configure the insecure registry by modifying your nix config: + virtualisation.docker = { + enable = true; + daemon.settings.insecure-registries = [ %q ]; + }; +`, registryAddr) + } + } + + if hasCommand("podman") { + fmt.Fprintf(out, `If podman was configured via Nix, this command will likely fail. +The configuration required is adding the following to registries.conf: + [[registry]] + location = %q + insecure = true +`, registryAddr) + } +} + +// Teardowns +// --------- + +// teardownRegistry stops and removes the shared registry container. Called +// from Delete when the last func-managed cluster is being removed. +func teardownRegistry(ctx context.Context, cfg ClusterConfig, out io.Writer) { + if err := run(ctx, out, "", cfg.ContainerEngine(), "rm", "-f", registryContainerName); err != nil { + fmt.Fprintf(out, "Warning: failed to remove registry container %q: %v\n", registryContainerName, err) + } +} + +// revertHostRegistry removes the insecure-registries entry we added at +// create time and the matching podman stanza. Best-effort: per-engine +// failures warn but don't abort the delete. +func revertHostRegistry(out io.Writer) { + if hasCommand("docker") { + if err := revertDockerHTTP(out); err != nil { + warnf(out, "failed to revert Docker insecure-registries: %v", err) + } + } + if hasCommand("podman") { + if err := revertPodmanHTTP(out); err != nil { + warnf(out, "failed to revert Podman registries.conf: %v", err) + } + } +} + +// revertDockerHTTP removes registryAddr from daemon.json's +// insecure-registries slice. No-op if the entry isn't there. +func revertDockerHTTP(out io.Writer) error { + path, useSudo := dockerConfigPath() + config, err := readDockerDaemon(path, useSudo) + if err != nil { + return err + } + changed, err := removeInsecureRegistry(config, registryAddr) + if err != nil { + return err + } + if !changed { + return nil + } + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("marshaling daemon.json: %w", err) + } + if err := writeFileWithSudo(path, data, useSudo); err != nil { + return fmt.Errorf("writing daemon.json: %w", err) + } + fmt.Fprintf(out, "Removed %s from %s\n", registryAddr, path) + if runtime.GOOS == "darwin" { + fmt.Fprintln(out, yellow("*** If Docker Desktop is running, please restart it via the menu bar icon ***")) + } + return nil +} + +// removeInsecureRegistry strips registry from config["insecure-registries"] +// if present, and removes the key entirely when the slice becomes empty. +// Returns (changed, error); errors if the existing value isn't a JSON +// array, rather than silently overwriting. +func removeInsecureRegistry(config map[string]any, registry string) (bool, error) { + raw, present := config["insecure-registries"] + if !present { + return false, nil + } + existing, ok := raw.([]any) + if !ok { + return false, fmt.Errorf("unexpected type for insecure-registries: %T (refusing to overwrite)", raw) + } + // In-place filter: `kept` reuses `existing`'s backing array. Safe here + // because writes never race reads (we only write at `len(kept)`, and + // the loop reads element `i` before we'd overwrite it). We reassign + // `config["insecure-registries"]` to `kept` at the end, so any trailing + // orphan elements in the original array become unreachable. + kept := existing[:0] + removed := false + for _, r := range existing { + if s, ok := r.(string); ok && s == registry { + removed = true + continue + } + kept = append(kept, r) + } + if !removed { + return false, nil + } + if len(kept) == 0 { + delete(config, "insecure-registries") + } else { + config["insecure-registries"] = kept + } + return true, nil +} + +// revertPodmanHTTP removes the v2 `[[registry]]` stanza we injected at +// create time. The block has a fixed shape, so a literal string match is +// reliable. v1 (`[registries.insecure]`) is not reverted — the format is +// deprecated and entries are typically shared across sections. +func revertPodmanHTTP(out io.Writer) error { + path, useSudo, exists := podmanConfigPath() + if !exists { + return nil + } + data, err := readFileWithSudo(path, useSudo) + if err != nil { + if errors.Is(err, fs.ErrNotExist) || !fileExists(path) { + return nil + } + return fmt.Errorf("reading %s: %w", path, err) + } + stanza := fmt.Sprintf("\n[[registry]]\nlocation = %q\ninsecure = true\n", registryAddr) + content := string(data) + if !strings.Contains(content, stanza) { + return nil + } + updated := strings.Replace(content, stanza, "", 1) + if err := writeFileWithSudo(path, []byte(updated), useSudo); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + fmt.Fprintf(out, "Removed %s from %s\n", registryAddr, path) + return nil +} + +// Helpers +// ------- + +// podmanConfigPath resolves Podman's registries.conf. The returned path +// is always populated; `exists` tells the caller whether the file is +// actually on disk (callers that want to *configure* create if absent, +// callers that want to *revert* no-op if absent). `useSudo` is only +// meaningful when exists=true, reflecting whether the file is the +// system-wide /etc path. When neither user nor system path exists, the +// user-level XDG path is returned as the default for create. +func podmanConfigPath() (path string, useSudo bool, exists bool) { + xdgConfig := os.Getenv("XDG_CONFIG_HOME") + if xdgConfig == "" { + home, _ := os.UserHomeDir() + xdgConfig = filepath.Join(home, ".config") + } + userPath := filepath.Join(xdgConfig, "containers", "registries.conf") + if fileExists(userPath) { + return userPath, false, true + } + systemPath := "/etc/containers/registries.conf" + if fileExists(systemPath) { + return systemPath, true, true + } + return userPath, false, false +} + +// dockerConfigPath returns the daemon.json path and whether writing it +// requires sudo. Darwin (Docker Desktop) uses the per-user path; Linux +// writes to /etc/docker/daemon.json, which requires root. +func dockerConfigPath() (path string, useSudo bool) { + if runtime.GOOS == "darwin" { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".docker", "daemon.json"), false + } + return "/etc/docker/daemon.json", true +} + +// readDockerDaemon loads daemon.json. A missing file returns an empty +// config (first-time setup); read/parse failures return an error so we +// don't silently overwrite a daemon.json the user has customized. +func readDockerDaemon(path string, useSudo bool) (map[string]any, error) { + data, err := readFileWithSudo(path, useSudo) + if errors.Is(err, fs.ErrNotExist) || (err != nil && !fileExists(path)) { + return map[string]any{}, nil + } + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + if len(data) == 0 { + return map[string]any{}, nil + } + var config map[string]any + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + if config == nil { + config = map[string]any{} + } + return config, nil +} + +func hasCommand(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func readFileWithSudo(path string, sudo bool) ([]byte, error) { + if !sudo { + return os.ReadFile(path) + } + out, err := exec.Command("sudo", "cat", path).Output() + if err != nil { + return nil, err + } + return out, nil +} + +func writeFileWithSudo(path string, data []byte, sudo bool) error { + if !sudo { + return os.WriteFile(path, data, 0o644) + } + cmd := exec.Command("sudo", "tee", path) + cmd.Stdin = strings.NewReader(string(data)) + cmd.Stdout = io.Discard + return cmd.Run() +} + +func appendFileWithSudo(path string, data []byte, sudo bool) error { + if !sudo { + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(data) + return err + } + cmd := exec.Command("sudo", "tee", "-a", path) + cmd.Stdin = strings.NewReader(string(data)) + cmd.Stdout = io.Discard + return cmd.Run() +} diff --git a/pkg/cluster/serving.go b/pkg/cluster/serving.go new file mode 100644 index 0000000000..a8e2ea448b --- /dev/null +++ b/pkg/cluster/serving.go @@ -0,0 +1,238 @@ +package cluster + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/yaml" +) + +// installServing installs Knative Serving CRDs and core components. +func installServing(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + start := time.Now() + status(out, "Installing Serving") + fmt.Fprintf(out, "Version: %s\n", servingVersion) + + // CRDs + err := run(ctx, out, "", + cfg.kubectl(), "apply", "--filename", + fmt.Sprintf("https://github.com/knative/serving/releases/download/knative-%s/serving-crds.yaml", servingVersion)) + if err != nil { + return fmt.Errorf("applying serving CRDs: %w", err) + } + + err = run(ctx, out, "", + cfg.kubectl(), "wait", "--for=condition=Established", "--all", "crd", "--timeout=5m") + if err != nil { + return fmt.Errorf("waiting for CRDs: %w", err) + } + + // Core + url := fmt.Sprintf("https://github.com/knative/serving/releases/download/knative-%s/serving-core.yaml", servingVersion) + if err := applyURL(ctx, out, cfg, url); err != nil { + return fmt.Errorf("applying serving core: %w", err) + } + + err = run(ctx, out, "", + cfg.kubectl(), "wait", "pod", + "--for=condition=Ready", "-l", "!job-name", "-n", "knative-serving", "--timeout=5m") + if err != nil { + return fmt.Errorf("waiting for serving pods: %w", err) + } + + _ = run(ctx, out, "", cfg.kubectl(), "get", "pod", "-A") + success(out, "Knative Serving", time.Since(start)) + return nil +} + +// configureDNS patches the Knative Serving config-domain to use the configured domain. +func configureDNS(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + start := time.Now() + status(out, "Configuring DNS") + + var lastErr error + for i := 0; i < 10; i++ { + lastErr = run(ctx, out, "", cfg.kubectl(), + "patch", "configmap/config-domain", + "--namespace", "knative-serving", + "--type", "merge", + "--patch", fmt.Sprintf(`{"data":{"%s":""}}`, cfg.Domain)) + if lastErr == nil { + success(out, "DNS", time.Since(start)) + return nil + } + fmt.Fprintln(out, "Retrying...") + if err := wait(ctx, 5*time.Second); err != nil { + return err + } + } + return fmt.Errorf("unable to set Knative domain after 10 attempts: %w", lastErr) +} + +// installNetworking installs Contour ingress controller and configures Knative +// to use it. The Contour YAML is modified in Go (replacing yq) to add IPv6 +// dual-stack support args. +func installNetworking(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + start := time.Now() + status(out, "Installing Ingress Controller (Contour)") + fmt.Fprintf(out, "Version: %s\n", contourVersion) + + fmt.Fprintln(out, "Installing a configured Contour.") + contourURL := fmt.Sprintf("https://github.com/knative/net-contour/releases/download/knative-%s/contour.yaml", contourVersion) + contourYAML, err := httpGet(ctx, contourURL) + if err != nil { + return fmt.Errorf("downloading contour YAML: %w", err) + } + + modifiedYAML, err := addContourIPv6Args(contourYAML) + if err != nil { + return fmt.Errorf("modifying contour YAML: %w", err) + } + + if err := run(ctx, out, modifiedYAML, cfg.kubectl(), "apply", "-f", "-"); err != nil { + return fmt.Errorf("applying contour: %w", err) + } + + err = run(ctx, out, "", + cfg.kubectl(), "wait", "pod", + "--for=condition=Ready", "-l", "!job-name", + "-n", "contour-external", "--timeout=10m") + if err != nil { + return fmt.Errorf("waiting for contour pods: %w", err) + } + + fmt.Fprintln(out, "Installing the Knative Contour controller.") + netContourURL := fmt.Sprintf("https://github.com/knative/net-contour/releases/download/knative-%s/net-contour.yaml", contourVersion) + if err := run(ctx, out, "", cfg.kubectl(), "apply", "-f", netContourURL); err != nil { + return fmt.Errorf("applying net-contour: %w", err) + } + + err = run(ctx, out, "", + cfg.kubectl(), "wait", "pod", + "--for=condition=Ready", "-l", "!job-name", + "-n", "knative-serving", "--timeout=10m") + if err != nil { + return fmt.Errorf("waiting for net-contour pods: %w", err) + } + + fmt.Fprintln(out, "Configuring Knative Serving to use Contour.") + err = run(ctx, out, "", + cfg.kubectl(), "patch", "configmap/config-network", + "--namespace", "knative-serving", + "--type", "merge", + "--patch", `{"data":{"ingress-class":"contour.ingress.networking.knative.dev"}}`) + if err != nil { + return fmt.Errorf("configuring contour ingress: %w", err) + } + + fmt.Fprintln(out, "Patch domain-template") + err = run(ctx, out, "", + cfg.kubectl(), "patch", "-n", "knative-serving", "cm/config-network", + "--patch", `{"data":{"domain-template":"{{.Name}}-{{.Namespace}}-ksvc.{{.Domain}}"}}`) + if err != nil { + return fmt.Errorf("patching domain-template: %w", err) + } + + fmt.Fprintln(out, "Patching contour to prefer dual-stack") + err = run(ctx, out, "", + cfg.kubectl(), "patch", "-n", "contour-external", "svc/envoy", + "--type", "merge", + "--patch", `{"spec":{"ipFamilyPolicy":"PreferDualStack"}}`) + if err != nil { + return fmt.Errorf("patching contour dual-stack: %w", err) + } + + err = run(ctx, out, "", + cfg.kubectl(), "wait", "pod", "--for=condition=Ready", "-l", "!job-name", + "-n", "contour-external", "--timeout=10m") + if err != nil { + return fmt.Errorf("waiting for contour: %w", err) + } + + err = run(ctx, out, "", + cfg.kubectl(), "wait", "pod", "--for=condition=Ready", "-l", "!job-name", + "-n", "knative-serving", "--timeout=10m") + if err != nil { + return fmt.Errorf("waiting for serving: %w", err) + } + + success(out, "Ingress", time.Since(start)) + return nil +} + +// addContourIPv6Args modifies the Contour deployment YAML to add +// --envoy-service-http-address=:: and --envoy-service-https-address=:: args. +// This replaces the yq pipeline from the shell script. The input is split +// on the multi-document separator and each non-empty chunk is decoded +// individually, which avoids the k8s YAML decoder's quirk of returning a +// string-compared "Object 'Kind' is missing" error for empty documents. +func addContourIPv6Args(yamlContent string) (string, error) { + var docs []unstructured.Unstructured + for _, chunk := range splitYAMLDocs(yamlContent) { + var obj unstructured.Unstructured + if err := yaml.NewYAMLOrJSONDecoder(strings.NewReader(chunk), 4096).Decode(&obj); err != nil { + return "", fmt.Errorf("decoding YAML: %w", err) + } + if obj.Object == nil { + continue + } + + if obj.GetKind() == "Deployment" && obj.GetName() == "contour" { + containers, found, err := unstructured.NestedSlice(obj.Object, "spec", "template", "spec", "containers") + if err == nil && found && len(containers) > 0 { + container := containers[0].(map[string]any) + argsRaw, _, _ := unstructured.NestedStringSlice(container, "args") + args := append(argsRaw, + "--envoy-service-http-address=::", + "--envoy-service-https-address=::", + ) + container["args"] = toAnySlice(args) + containers[0] = container + _ = unstructured.SetNestedSlice(obj.Object, containers, "spec", "template", "spec", "containers") + } + } + + docs = append(docs, obj) + } + + var buf bytes.Buffer + for i, doc := range docs { + if i > 0 { + buf.WriteString("---\n") + } + b, err := doc.MarshalJSON() + if err != nil { + return "", fmt.Errorf("marshaling document: %w", err) + } + buf.Write(b) + buf.WriteByte('\n') + } + + return buf.String(), nil +} + +// splitYAMLDocs splits a multi-document YAML string on the "---" separator +// and returns non-empty documents. Whitespace-only chunks are skipped. +func splitYAMLDocs(content string) []string { + var docs []string + for _, chunk := range strings.Split(content, "\n---") { + if strings.TrimSpace(chunk) == "" { + continue + } + docs = append(docs, chunk) + } + return docs +} + +func toAnySlice(ss []string) []any { + result := make([]any, len(ss)) + for i, s := range ss { + result[i] = s + } + return result +} diff --git a/pkg/cluster/tekton.go b/pkg/cluster/tekton.go new file mode 100644 index 0000000000..7d1d760448 --- /dev/null +++ b/pkg/cluster/tekton.go @@ -0,0 +1,112 @@ +package cluster + +import ( + "context" + "fmt" + "io" + "time" +) + +// installTekton installs Tekton Pipelines and configures RBAC. +func installTekton(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + start := time.Now() + status(out, "Installing Tekton") + fmt.Fprintf(out, "Version: %s\n", tektonVersion) + + tektonRelease := "previous/" + tektonVersion + namespace := cfg.Namespace + + url := fmt.Sprintf("https://storage.googleapis.com/tekton-releases/pipeline/%s/release.yaml", tektonRelease) + if err := run(ctx, out, "", cfg.kubectl(), "apply", "-f", url); err != nil { + return fmt.Errorf("applying tekton: %w", err) + } + + if err := run(ctx, out, "", + cfg.kubectl(), "wait", "pod", + "--for=condition=Ready", "--timeout=180s", + "-n", "tekton-pipelines", "-l", "app=tekton-pipelines-controller"); err != nil { + return fmt.Errorf("waiting for tekton controller: %w", err) + } + + if err := run(ctx, out, "", + cfg.kubectl(), "wait", "pod", + "--for=condition=Ready", "--timeout=180s", + "-n", "tekton-pipelines", "-l", "app=tekton-pipelines-webhook"); err != nil { + return fmt.Errorf("waiting for tekton webhook: %w", err) + } + + // RBAC bindings (apply for idempotency) + rbacBindings := []struct{ name, role string }{ + {namespace + ":knative-serving-namespaced-admin", "knative-serving-namespaced-admin"}, + {namespace + ":admin", "admin"}, + {namespace + ":keda-add-ons-http-operator", "keda-add-ons-http-operator"}, + } + for _, rb := range rbacBindings { + manifest := fmt.Sprintf(`apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: %s +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: %s +subjects: +- kind: ServiceAccount + name: default + namespace: %s +`, rb.name, rb.role, namespace) + if err := applyManifest(ctx, out, cfg, manifest); err != nil { + return fmt.Errorf("applying clusterrolebinding %s: %w", rb.name, err) + } + } + + success(out, "Tekton", time.Since(start)) + return nil +} + +// installPAC installs Pipelines-as-Code and creates its ingress. +func installPAC(ctx context.Context, cfg ClusterConfig, out io.Writer) error { + start := time.Now() + status(out, "Installing Pipelines-as-Code") + fmt.Fprintf(out, "Version: %s\n", pacVersion) + + url := fmt.Sprintf("https://raw.githubusercontent.com/openshift-pipelines/pipelines-as-code/release-%s/release.k8s.yaml", pacVersion) + if err := run(ctx, out, "", cfg.kubectl(), "apply", "-f", url); err != nil { + return fmt.Errorf("applying PAC: %w", err) + } + + if err := run(ctx, out, "", + cfg.kubectl(), "wait", "pod", + "--for=condition=Ready", "-l", "!job-name", + "-n", "pipelines-as-code", "--timeout=5m"); err != nil { + return fmt.Errorf("waiting for PAC: %w", err) + } + + pacIngress := fmt.Sprintf(`apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: pipelines-as-code + namespace: pipelines-as-code +spec: + ingressClassName: contour-external + rules: + - host: %s + http: + paths: + - backend: + service: + name: pipelines-as-code-controller + port: + number: 8080 + pathType: Prefix + path: / +`, cfg.PacHost) + + if err := applyManifest(ctx, out, cfg, pacIngress); err != nil { + return fmt.Errorf("applying PAC ingress: %w", err) + } + + fmt.Fprintf(out, "the Pipeline as Code controller is available at: http://%s\n", cfg.PacHost) + success(out, "PAC", time.Since(start)) + return nil +} diff --git a/pkg/cluster/versions.go b/pkg/cluster/versions.go new file mode 100644 index 0000000000..49350ac9a0 --- /dev/null +++ b/pkg/cluster/versions.go @@ -0,0 +1,42 @@ +package cluster + +// Component versions — Kubernetes and Knative ecosystem components installed +// into the cluster. Source of truth previously split across +// hack/component-versions.json and hardcoded values in hack/cluster.sh. +const ( + kindNodeVersion = "v1.34.0@sha256:7416a61b42b1662ca6ca89f02028ac133a309a2a30ba309614e8ec94d976dc5a" + servingVersion = "v1.21.2" + eventingVersion = "v1.21.2" + contourVersion = "v1.21.1" + tektonVersion = "v1.1.0" + pacVersion = "v0.35.2" + kedaVersion = "v2.17.0" + kedaHTTPAddOnVersion = "v0.12.0" + metalLBVersion = "v0.13.7" +) + +// Tool versions — only tools we download and manage. +const ( + kubectlVersion = "1.33.1" + kindVersion = "0.31.0" +) + +// kubectlChecksums pins the expected SHA-256 of the kubectl binary for each +// supported os/arch at kubectlVersion. Update in lockstep with kubectlVersion. +// Sourced from https://dl.k8s.io/v/bin///kubectl.sha256. +var kubectlChecksums = map[string]string{ + "linux/amd64": "5de4e9f2266738fd112b721265a0c1cd7f4e5208b670f811861f699474a100a3", + "linux/arm64": "d595d1a26b7444e0beb122e25750ee4524e74414bbde070b672b423139295ce6", + "darwin/amd64": "8d36a5c66142547ad16e332942fd16a0ca2b3346d9ebaab6c348de2c70d9d875", + "darwin/arm64": "8ae6823839993bb2e394c3cf1919748e530642c625dc9100159595301f53bdeb", +} + +// kindChecksums pins the expected SHA-256 of the kind binary for each +// supported os/arch at kindVersion. Update in lockstep with kindVersion. +// Sourced from the kind--.sha256sum files on the GitHub release. +var kindChecksums = map[string]string{ + "linux/amd64": "eb244cbafcc157dff60cf68693c14c9a75c4e6e6fedaf9cd71c58117cb93e3fa", + "linux/arm64": "8e1014e87c34901cc422a1445866835d1e666f2a61301c27e722bdeab5a1f7e4", + "darwin/amd64": "a8b3cf77b2ad77aec5bf710d1a2589d9117576132af812885cad41e9dede4d4e", + "darwin/arm64": "88bf554fe9da6311c9f8c2d082613c002911a476f6b5090e9420b35d84e70c5c", +}