mirror of
https://github.com/tailscale/tailscale.git
synced 2026-09-15 07:20:36 -04:00
Extend e2e test setup to work with a remote registry and real cluster. Previously only kind was supported. Detect cluster node architecture and build images only for the given architecture to reduce build time. Build for all platforms as the fallback option. This assumes only one node architecture per cluster, which is reasonable for our test cases. By default, --build loads the built images into a kind cluster. Use with --registry to instead specify and push to a remote registry. Fixes tailscale/corp#46577 Signed-off-by: Becky Pauley <becky@tailscale.com>
1196 lines
40 KiB
Go
1196 lines
40 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package e2e
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-logr/zapr"
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
"github.com/google/go-containerregistry/pkg/v1/daemon"
|
|
"github.com/google/go-containerregistry/pkg/v1/tarball"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/oauth2/clientcredentials"
|
|
"helm.sh/helm/v3/pkg/action"
|
|
"helm.sh/helm/v3/pkg/chart"
|
|
"helm.sh/helm/v3/pkg/chart/loader"
|
|
"helm.sh/helm/v3/pkg/cli"
|
|
"helm.sh/helm/v3/pkg/release"
|
|
"helm.sh/helm/v3/pkg/storage/driver"
|
|
corev1 "k8s.io/api/core/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/watch"
|
|
"k8s.io/client-go/rest"
|
|
"k8s.io/client-go/tools/clientcmd"
|
|
"k8s.io/client-go/tools/portforward"
|
|
"k8s.io/client-go/transport/spdy"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
klog "sigs.k8s.io/controller-runtime/pkg/log"
|
|
kzap "sigs.k8s.io/controller-runtime/pkg/log/zap"
|
|
"sigs.k8s.io/kind/pkg/apis/config/v1alpha4"
|
|
"sigs.k8s.io/kind/pkg/cluster"
|
|
"sigs.k8s.io/kind/pkg/cluster/nodeutils"
|
|
"sigs.k8s.io/kind/pkg/cmd"
|
|
|
|
"tailscale.com/client/tailscale/v2"
|
|
"tailscale.com/ipn"
|
|
"tailscale.com/ipn/store/mem"
|
|
tsoperator "tailscale.com/k8s-operator"
|
|
tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
|
|
"tailscale.com/tsnet"
|
|
"tailscale.com/util/must"
|
|
)
|
|
|
|
const (
|
|
pebbleTag = "2.8.0"
|
|
ns = "default"
|
|
tmp = "/tmp/k8s-operator-e2e"
|
|
kindClusterName = "k8s-operator-e2e"
|
|
testCAsConfigMap = "test-cas"
|
|
testCAsConfigMapKey = "test-cas.pem"
|
|
imgOperator = "k8s-operator"
|
|
imgTailscale = "tailscale"
|
|
imgProxy = "k8s-proxy"
|
|
imgNameserver = "k8s-nameserver"
|
|
)
|
|
|
|
var (
|
|
tsClient *tailscale.Client // For API calls to control.
|
|
tnClient *tsnet.Server // For testing real tailnet traffic on first tailnet.
|
|
secondTSClient *tailscale.Client // For API calls to the secondary tailnet (_second_tailnet).
|
|
secondTNClient *tsnet.Server // For testing real tailnet traffic on second tailnet.
|
|
restCfg *rest.Config // For constructing a client-go client if necessary.
|
|
kubeClient client.WithWatch // For k8s API calls.
|
|
clusterLoginServer string
|
|
clusterIPv4Support bool // whether the test cluster supports IPv4.
|
|
clusterIPv6Support bool // whether the test cluster supports IPv6.
|
|
|
|
//go:embed certs/pebble.minica.crt
|
|
pebbleMiniCACert []byte
|
|
|
|
// Let's Encrypt staging environment root "Pretend Pear X1", used when
|
|
// running against real tailnets.
|
|
// Available from https://letsencrypt.org/certs/staging/letsencrypt-stg-root-x1.pem
|
|
//go:embed certs/letsencrypt-stg-root-x1.pem
|
|
leStagingRootX1 []byte
|
|
|
|
// Either pebble CAs (if pebble is deployed for devcontrol) or Let's Encrypt
|
|
// staging (when running against real tailnets).
|
|
// pebble has a static "mini" CA that its ACME directory URL serves a cert from,
|
|
// and also dynamically generates a different CA for issuing certs.
|
|
testCAs *x509.CertPool
|
|
|
|
//go:embed acl.hujson
|
|
requiredACLs []byte
|
|
|
|
fDevcontrol = flag.Bool("devcontrol", false, "if true, connect to devcontrol at http://localhost:31544. Run devcontrol with "+`
|
|
./tool/go run ./cmd/devcontrol \
|
|
--generate-test-devices=k8s-operator-e2e \
|
|
--dir=/tmp/devcontrol \
|
|
--scenario-output-dir=/tmp/k8s-operator-e2e \
|
|
--test-dns=http://localhost:8055`)
|
|
fSkipCleanup = flag.Bool("skip-cleanup", false, "if true, do not delete the kind cluster (if created) or tmp dir on exit")
|
|
fCluster = flag.Bool("cluster", false, "if true, create or use a pre-existing kind cluster named k8s-operator-e2e; otherwise assume a usable cluster already exists in kubeconfig")
|
|
fBuild = flag.Bool("build", false, "if true, build and deploy the operator and container images from the current checkout; otherwise assume the operator is already set up")
|
|
fBaseImage = flag.String("base-image", "", "if set, use this image as the base for all images built by --build, instead of the default base image in build_docker.sh")
|
|
fRegistry = flag.String("registry", "", `if set, build and push images instead of loading them into a kind node. Required with --build when testing against a remote cluster.`)
|
|
)
|
|
|
|
func runTests(m *testing.M) (int, error) {
|
|
logger := kzap.NewRaw().Sugar()
|
|
klog.SetLogger(zapr.NewLogger(logger.Desugar()))
|
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
|
defer cancel()
|
|
|
|
switch {
|
|
case *fRegistry != "" && !*fBuild:
|
|
return 0, fmt.Errorf("--registry requires --build (there is nothing to push otherwise)")
|
|
case *fBuild && *fCluster && *fRegistry != "":
|
|
return 0, fmt.Errorf("--build takes --cluster (side-load into the kind node) or --registry (push to a remote), not both")
|
|
case *fBuild && !*fCluster && *fRegistry == "":
|
|
return 0, fmt.Errorf("--build without --cluster needs --registry to push images to; there is no kind node to side-load into")
|
|
}
|
|
|
|
ossDir, err := gitRootDir()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if err = os.MkdirAll(tmp, 0755); err != nil {
|
|
return 0, fmt.Errorf("failed to create temp dir: %w", err)
|
|
}
|
|
|
|
logger.Infof("temp dir: %q", tmp)
|
|
logger.Infof("oss dir: %q", ossDir)
|
|
|
|
var (
|
|
kubeconfig string
|
|
kindProvider *cluster.Provider
|
|
)
|
|
if *fCluster {
|
|
kubeconfig = filepath.Join(tmp, "kubeconfig")
|
|
kindProvider = cluster.NewProvider(
|
|
cluster.ProviderWithLogger(cmd.NewLogger()),
|
|
)
|
|
|
|
clusters, err := kindProvider.List()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to list kind clusters: %w", err)
|
|
}
|
|
|
|
if !slices.Contains(clusters, kindClusterName) {
|
|
if err := kindProvider.Create(kindClusterName,
|
|
cluster.CreateWithV1Alpha4Config(&v1alpha4.Cluster{
|
|
Networking: v1alpha4.Networking{
|
|
IPFamily: v1alpha4.DualStackFamily,
|
|
},
|
|
}),
|
|
cluster.CreateWithWaitForReady(5*time.Minute),
|
|
cluster.CreateWithKubeconfigPath(kubeconfig),
|
|
cluster.CreateWithNodeImage("kindest/node:v1.35.0"),
|
|
); err != nil {
|
|
return 0, fmt.Errorf("failed to create kind cluster: %w", err)
|
|
}
|
|
}
|
|
|
|
if !*fSkipCleanup {
|
|
defer kindProvider.Delete(kindClusterName, kubeconfig)
|
|
defer os.Remove(kubeconfig)
|
|
}
|
|
}
|
|
|
|
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
|
|
if kubeconfig != "" {
|
|
loadingRules.ExplicitPath = kubeconfig
|
|
}
|
|
restCfg, err = clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}).ClientConfig()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("error loading kubeconfig: %w", err)
|
|
}
|
|
|
|
kubeClient, err = client.NewWithWatch(restCfg, client.Options{Scheme: tsapi.GlobalScheme})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("error creating Kubernetes client: %w", err)
|
|
}
|
|
|
|
if err := detectClusterIPFamilies(ctx, logger, kubeClient); err != nil {
|
|
return 0, fmt.Errorf("error detecting cluster IP families: %w", err)
|
|
}
|
|
|
|
var (
|
|
clientID, clientSecret string // OAuth client for the first tailnet (for the operator to use).
|
|
secondClientID, secondClientSecret string // OAuth client for the second tailnet (for the operator to use).
|
|
|
|
caPaths []string // Extra CA cert file paths to add to images.
|
|
certsDir = filepath.Join(tmp, "certs") // Directory containing extra CA certs to add to images.
|
|
caPEM []byte // Used to collect and then publish test-cas ConfigMap.
|
|
)
|
|
testCAs = x509.NewCertPool()
|
|
if *fDevcontrol {
|
|
// Deploy pebble and get its certs.
|
|
if err = applyPebbleResources(ctx, kubeClient); err != nil {
|
|
return 0, fmt.Errorf("failed to apply pebble resources: %w", err)
|
|
}
|
|
|
|
pebblePod, err := waitForPodReady(ctx, logger, kubeClient, ns, client.MatchingLabels{"app": "pebble"})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("pebble pod not ready: %w", err)
|
|
}
|
|
|
|
if err = forwardLocalPortToPod(ctx, logger, restCfg, ns, pebblePod, 15000); err != nil {
|
|
return 0, fmt.Errorf("failed to set up port forwarding to pebble: %w", err)
|
|
}
|
|
|
|
if ok := testCAs.AppendCertsFromPEM(pebbleMiniCACert); !ok {
|
|
return 0, fmt.Errorf("failed to parse pebble minica cert")
|
|
}
|
|
|
|
var pebbleCAChain []byte
|
|
for _, path := range []string{"/intermediates/0", "/roots/0"} {
|
|
pem, err := pebbleGet(ctx, 15000, path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
pebbleCAChain = append(pebbleCAChain, pem...)
|
|
}
|
|
|
|
if ok := testCAs.AppendCertsFromPEM(pebbleCAChain); !ok {
|
|
return 0, fmt.Errorf("failed to parse pebble ca chain cert")
|
|
}
|
|
caPEM = appendPEM(caPEM, pebbleMiniCACert, pebbleCAChain)
|
|
|
|
if err = os.MkdirAll(certsDir, 0755); err != nil {
|
|
return 0, fmt.Errorf("failed to create certs dir: %w", err)
|
|
}
|
|
|
|
pebbleCAChainPath := filepath.Join(certsDir, "pebble-ca-chain.crt")
|
|
if err = os.WriteFile(pebbleCAChainPath, pebbleCAChain, 0644); err != nil {
|
|
return 0, fmt.Errorf("failed to write pebble CA chain: %w", err)
|
|
}
|
|
|
|
pebbleMiniCACertPath := filepath.Join(certsDir, "pebble.minica.crt")
|
|
if err = os.WriteFile(pebbleMiniCACertPath, pebbleMiniCACert, 0644); err != nil {
|
|
return 0, fmt.Errorf("failed to write pebble minica: %w", err)
|
|
}
|
|
|
|
caPaths = []string{pebbleCAChainPath, pebbleMiniCACertPath}
|
|
if !*fSkipCleanup {
|
|
defer os.RemoveAll(certsDir)
|
|
}
|
|
|
|
// Set up network connectivity between cluster and devcontrol.
|
|
//
|
|
// For devcontrol -> pebble (DNS mgmt for ACME challenges):
|
|
// * Port forward from localhost port 8055 to in-cluster pebble port 8055.
|
|
//
|
|
// For Pods -> devcontrol (tailscale clients joining the tailnet):
|
|
// * Create ssh-server Deployment in cluster.
|
|
// * Create reverse ssh tunnel that goes from ssh-server port 31544 to localhost:31544.
|
|
if err = forwardLocalPortToPod(ctx, logger, restCfg, ns, pebblePod, 8055); err != nil {
|
|
return 0, fmt.Errorf("failed to set up port forwarding to pebble: %w", err)
|
|
}
|
|
|
|
privateKey, publicKey, err := readOrGenerateSSHKey(tmp)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to read or generate SSH key: %w", err)
|
|
}
|
|
|
|
if !*fSkipCleanup {
|
|
defer os.Remove(privateKeyPath)
|
|
}
|
|
|
|
sshServiceIP, err := connectClusterToDevcontrol(ctx, logger, kubeClient, restCfg, privateKey, publicKey)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to set up cluster->devcontrol connection: %w", err)
|
|
}
|
|
|
|
if !*fSkipCleanup {
|
|
defer func() {
|
|
if err := cleanupSSHResources(context.Background(), kubeClient); err != nil {
|
|
logger.Infof("failed to clean up ssh-server resources: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Address cluster workloads can reach devcontrol at. Must be a private
|
|
// IP to make sure tailscale client code recognises it shouldn't try an
|
|
// https fallback. See [controlclient.NewNoiseClient] for details.
|
|
clusterLoginServer = fmt.Sprintf("http://%s:31544", sshServiceIP)
|
|
|
|
b, err := os.ReadFile(filepath.Join(tmp, "api-key.json"))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to read api-key.json: %w", err)
|
|
}
|
|
var apiKeyData struct {
|
|
APIKey string `json:"apiKey"`
|
|
}
|
|
if err = json.Unmarshal(b, &apiKeyData); err != nil {
|
|
return 0, fmt.Errorf("failed to parse api-key.json: %w", err)
|
|
}
|
|
if apiKeyData.APIKey == "" {
|
|
return 0, fmt.Errorf("api-key.json did not contain an API key")
|
|
}
|
|
|
|
// Finish setting up tsClient.
|
|
tsClient = &tailscale.Client{
|
|
APIKey: apiKeyData.APIKey,
|
|
BaseURL: must.Get(url.Parse("http://localhost:31544")),
|
|
}
|
|
|
|
// Set ACLs and create OAuth client.
|
|
if err = tsClient.PolicyFile().Set(ctx, string(requiredACLs), ""); err != nil {
|
|
return 0, fmt.Errorf("failed to set policy file: %w", err)
|
|
}
|
|
|
|
logger.Info("ACLs configured for first tailnet")
|
|
|
|
key, err := tsClient.Keys().CreateOAuthClient(ctx, tailscale.CreateOAuthClientRequest{
|
|
Scopes: []string{"auth_keys", "devices:core", "services"},
|
|
Tags: []string{"tag:k8s-operator"},
|
|
Description: "k8s-operator client for e2e tests",
|
|
})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to create OAuth client for first tailnet: %w", err)
|
|
}
|
|
clientID = key.ID
|
|
clientSecret = key.Key
|
|
|
|
logger.Info("OAuth credentials set for first tailnet")
|
|
|
|
// Create second tailnet. The bootstrap credentials returned have 'all' permissions-
|
|
// they are used for administrative actions and to create a separately scoped
|
|
// Oauth client for the k8s operator.
|
|
bootstrapClient, err := createTailnet(ctx, tsClient)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to create second tailnet: %w", err)
|
|
}
|
|
|
|
// Set HTTPS on second tailnet.
|
|
err = bootstrapClient.TailnetSettings().Update(ctx, tailscale.UpdateTailnetSettingsRequest{HTTPSEnabled: new(true)})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to configure https for second tailnet: %w", err)
|
|
}
|
|
logger.Info("HTTPS settings configured for second tailnet")
|
|
|
|
// Set ACLs for second tailnet.
|
|
if err = bootstrapClient.PolicyFile().Set(ctx, string(requiredACLs), ""); err != nil {
|
|
return 0, fmt.Errorf("failed to set policy file: %w", err)
|
|
}
|
|
|
|
logger.Info("ACLs configured for second tailnet")
|
|
|
|
// Create an OAuth client for the second tailnet to be used
|
|
// by the k8s-operator.
|
|
secondKey, err := bootstrapClient.Keys().CreateOAuthClient(ctx, tailscale.CreateOAuthClientRequest{
|
|
Scopes: []string{"auth_keys", "devices:core", "services"},
|
|
Tags: []string{"tag:k8s-operator"},
|
|
Description: "k8s-operator client for e2e tests",
|
|
})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to create OAuth client for second tailnet: %w", err)
|
|
}
|
|
secondClientID = secondKey.ID
|
|
secondClientSecret = secondKey.Key
|
|
|
|
secondTSClient, err = tailscaleClientFromSecret(ctx, "http://localhost:31544", secondClientID, secondClientSecret)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to set up second tailnet client: %w", err)
|
|
}
|
|
|
|
} else {
|
|
if ok := testCAs.AppendCertsFromPEM(leStagingRootX1); !ok {
|
|
return 0, fmt.Errorf("failed to parse Let's Encrypt staging root")
|
|
}
|
|
caPEM = appendPEM(caPEM, leStagingRootX1)
|
|
clientSecret = os.Getenv("TS_API_CLIENT_SECRET")
|
|
if clientSecret == "" {
|
|
return 0, fmt.Errorf("must use --devcontrol or set TS_API_CLIENT_SECRET to an OAuth client suitable for the operator")
|
|
}
|
|
clientID, err = clientIDFromSecret(clientSecret)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to get client id from secret: %w", err)
|
|
}
|
|
tsClient, err = tailscaleClientFromSecret(ctx, ipn.DefaultControlURL, clientID, clientSecret)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to set up first tailnet client: %w", err)
|
|
}
|
|
secondClientSecret = os.Getenv("SECOND_TS_API_CLIENT_SECRET")
|
|
if secondClientSecret == "" {
|
|
return 0, fmt.Errorf("must use --devcontrol or set SECOND_TS_API_CLIENT_SECRET to an OAuth client suitable for the operator")
|
|
}
|
|
secondClientID, err = clientIDFromSecret(secondClientSecret)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to get client id from secret: %w", err)
|
|
}
|
|
secondTSClient, err = tailscaleClientFromSecret(ctx, ipn.DefaultControlURL, secondClientID, secondClientSecret)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to set up second tailnet client: %w", err)
|
|
}
|
|
}
|
|
|
|
// Publish the trustedCAs as a ConfigMap that can be used by in-cluster
|
|
// testing workloads.
|
|
if len(caPEM) > 0 {
|
|
caCM := &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{Name: testCAsConfigMap, Namespace: ns},
|
|
Data: map[string]string{testCAsConfigMapKey: string(caPEM)},
|
|
}
|
|
if err := createOrUpdate(ctx, kubeClient, caCM); err != nil {
|
|
return 0, fmt.Errorf("failed to publish test CAs ConfigMap: %w", err)
|
|
}
|
|
defer kubeClient.Delete(context.Background(), caCM)
|
|
}
|
|
|
|
var ossTag string
|
|
if *fBuild {
|
|
// TODO(tomhjp): proper support for --build=false and layering pebble certs on top of existing images.
|
|
// TODO(tomhjp): build tsrecorder as well.
|
|
|
|
// Build tailscale/k8s-operator, tailscale/tailscale, tailscale/k8s-proxy, tailscale/k8s-nameserver, with pebble CAs added.
|
|
ossTag, err = tagForRepo(ossDir)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
logger.Infof("using OSS image tag: %q", ossTag)
|
|
if *fBaseImage != "" {
|
|
logger.Infof("using base image: %q", *fBaseImage)
|
|
}
|
|
ossImageToTarget := map[string]string{
|
|
imgOperator: "publishdevoperator",
|
|
imgTailscale: "publishdevimage",
|
|
imgProxy: "publishdevproxy",
|
|
imgNameserver: "publishdevnameserver",
|
|
}
|
|
var nodeArch string
|
|
if *fRegistry != "" {
|
|
nodeArch, err = detectNodeArch(ctx, kubeClient)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to detect node architecture: %w", err)
|
|
}
|
|
logger.Infof("building images for node architecture %q, pushing to %q", nodeArch, *fRegistry)
|
|
}
|
|
for img, target := range ossImageToTarget {
|
|
repo := imageRepo(img)
|
|
if err := buildImage(ctx, ossDir, repo, target, ossTag, nodeArch, *fBaseImage, caPaths); err != nil {
|
|
return 0, err
|
|
}
|
|
if *fRegistry != "" {
|
|
// Image was pushed to the registry, nothing to load into kind.
|
|
continue
|
|
}
|
|
nodes, err := kindProvider.ListInternalNodes(kindClusterName)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to list kind nodes: %w", err)
|
|
}
|
|
// TODO(tomhjp): can be made more efficient and portable if we
|
|
// stream built image tarballs straight to the node rather than
|
|
// going via the daemon.
|
|
imgRef, err := name.ParseReference(fmt.Sprintf("%s:%s", repo, ossTag))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to parse image reference: %w", err)
|
|
}
|
|
img, err := daemon.Image(imgRef)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to get image from daemon: %w", err)
|
|
}
|
|
pr, pw := io.Pipe()
|
|
go func() {
|
|
defer pw.Close()
|
|
if err := tarball.Write(imgRef, img, pw); err != nil {
|
|
logger.Infof("failed to write image to pipe: %v", err)
|
|
}
|
|
}()
|
|
for _, n := range nodes {
|
|
if err := nodeutils.LoadImageArchive(n, pr); err != nil {
|
|
return 0, fmt.Errorf("failed to load image into node %q: %w", n.String(), err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate CRDs for the helm chart.
|
|
cmd := exec.CommandContext(ctx, "go", "run", "tailscale.com/cmd/k8s-operator/generate", "helmcrd")
|
|
cmd.Dir = ossDir
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to generate CRD: %v: %s", err, out)
|
|
}
|
|
|
|
// Load and install helm chart.
|
|
chart, err := loader.Load(filepath.Join(ossDir, "cmd", "k8s-operator", "deploy", "chart"))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to load helm chart: %w", err)
|
|
}
|
|
// Image repo/tag are left empty unless we built the images (ossTag is set),
|
|
// so the chart uses the 'stable' defaults.
|
|
var operatorRepo, proxyRepo, imageTag string
|
|
var extraEnv []map[string]any
|
|
if ossTag != "" {
|
|
operatorRepo, proxyRepo, imageTag = imageRepo(imgOperator), imageRepo(imgTailscale), ossTag
|
|
extraEnv = append(extraEnv, map[string]any{"name": "K8S_PROXY_IMAGE", "value": imageRepo(imgProxy) + ":" + ossTag})
|
|
}
|
|
if *fDevcontrol {
|
|
extraEnv = append(extraEnv, map[string]any{"name": "TS_DEBUG_ACME_DIRECTORY_URL", "value": "https://pebble:14000/dir"})
|
|
} else {
|
|
extraEnv = append(extraEnv, map[string]any{"name": "TS_DEBUG_ACME_DIRECTORY_URL", "value": "https://acme-staging-v02.api.letsencrypt.org/directory"})
|
|
}
|
|
values := map[string]any{
|
|
"loginServer": clusterLoginServer,
|
|
"oauth": map[string]any{
|
|
"clientId": clientID,
|
|
"clientSecret": clientSecret,
|
|
},
|
|
"apiServerProxyConfig": map[string]any{
|
|
"mode": "true",
|
|
},
|
|
"operatorConfig": map[string]any{
|
|
"logging": "debug",
|
|
"extraEnv": extraEnv,
|
|
"image": map[string]any{
|
|
"repo": operatorRepo,
|
|
"tag": imageTag,
|
|
"pullPolicy": "IfNotPresent",
|
|
},
|
|
},
|
|
"proxyConfig": map[string]any{
|
|
"defaultProxyClass": "default",
|
|
"image": map[string]any{
|
|
"repo": proxyRepo,
|
|
"tag": imageTag,
|
|
},
|
|
},
|
|
}
|
|
|
|
settings := cli.New()
|
|
settings.KubeConfig = kubeconfig
|
|
settings.SetNamespace("tailscale")
|
|
helmCfg := &action.Configuration{}
|
|
if err := helmCfg.Init(settings.RESTClientGetter(), "tailscale", "", logger.Infof); err != nil {
|
|
return 0, fmt.Errorf("failed to initialize helm action configuration: %w", err)
|
|
}
|
|
|
|
const relName = "tailscale-operator" // TODO(tomhjp): maybe configurable if others use a different value.
|
|
f := upgraderOrInstaller(helmCfg, relName)
|
|
if _, err := f(ctx, relName, chart, values); err != nil {
|
|
return 0, fmt.Errorf("failed to install %q via helm: %w", relName, err)
|
|
}
|
|
|
|
if err := applyDefaultProxyClass(ctx, logger, kubeClient); err != nil {
|
|
return 0, fmt.Errorf("failed to apply default ProxyClass: %w", err)
|
|
}
|
|
|
|
// Leave the nameserver image unset when nothing was built so
|
|
// the operator falls back to the default.
|
|
nameserverImg := &tsapi.NameserverImage{}
|
|
if ossTag != "" {
|
|
nameserverImg.Repo = imageRepo(imgNameserver)
|
|
nameserverImg.Tag = ossTag
|
|
}
|
|
dnsConfig, err := deployNameserver(ctx, logger, kubeClient, nameserverImg)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to deploy nameserver: %w", err)
|
|
}
|
|
defer kubeClient.Delete(context.Background(), dnsConfig)
|
|
|
|
restoreClusterDNS, err := patchClusterDNS(ctx, logger, dnsConfig.Status.Nameserver.IP)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to patch cluster DNS: %w", err)
|
|
}
|
|
defer restoreClusterDNS()
|
|
|
|
caps := tailscale.KeyCapabilities{}
|
|
caps.Devices.Create.Preauthorized = true
|
|
caps.Devices.Create.Ephemeral = true
|
|
caps.Devices.Create.Tags = []string{"tag:k8s"}
|
|
|
|
authKey, err := tsClient.Keys().CreateAuthKey(ctx, tailscale.CreateKeyRequest{Capabilities: caps})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to create auth key for first tailnet: %w", err)
|
|
}
|
|
defer tsClient.Keys().Delete(context.Background(), authKey.ID)
|
|
|
|
secondAuthKey, err := secondTSClient.Keys().CreateAuthKey(ctx, tailscale.CreateKeyRequest{Capabilities: caps})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to create auth key for second tailnet: %w", err)
|
|
}
|
|
defer secondTSClient.Keys().Delete(context.Background(), secondAuthKey.ID)
|
|
|
|
tnClient = &tsnet.Server{
|
|
ControlURL: tsClient.BaseURL.String(),
|
|
Hostname: "test-proxy",
|
|
Ephemeral: true,
|
|
Store: &mem.Store{},
|
|
AuthKey: authKey.Key,
|
|
}
|
|
_, err = tnClient.Up(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer tnClient.Close()
|
|
|
|
secondTNClient = &tsnet.Server{
|
|
ControlURL: secondTSClient.BaseURL.String(),
|
|
Hostname: "test-proxy",
|
|
Ephemeral: true,
|
|
Store: &mem.Store{},
|
|
AuthKey: secondAuthKey.Key,
|
|
}
|
|
_, err = secondTNClient.Up(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer secondTNClient.Close()
|
|
|
|
// Create the tailnet Secret in the tailscale namespace.
|
|
secret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "second-tailnet-credentials",
|
|
Namespace: "tailscale",
|
|
},
|
|
Data: map[string][]byte{
|
|
"client_id": []byte(secondClientID),
|
|
"client_secret": []byte(secondClientSecret),
|
|
},
|
|
}
|
|
if err := createOrUpdate(ctx, kubeClient, secret); err != nil {
|
|
return 0, fmt.Errorf("failed to create second-tailnet-credentials Secret: %w", err)
|
|
}
|
|
defer kubeClient.Delete(context.Background(), secret)
|
|
|
|
// Create the Tailnet resource.
|
|
tn := &tsapi.Tailnet{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "second-tailnet",
|
|
},
|
|
Spec: tsapi.TailnetSpec{
|
|
LoginURL: clusterLoginServer,
|
|
Credentials: tsapi.TailnetCredentials{
|
|
SecretName: "second-tailnet-credentials",
|
|
},
|
|
},
|
|
}
|
|
if err := createOrUpdate(ctx, kubeClient, tn); err != nil {
|
|
return 0, fmt.Errorf("failed to create second-tailnet Tailnet: %w", err)
|
|
}
|
|
defer kubeClient.Delete(context.Background(), tn)
|
|
|
|
return m.Run(), nil
|
|
}
|
|
|
|
func clientIDFromSecret(clientSecret string) (string, error) {
|
|
// Format is "tskey-client-<id>-<random>".
|
|
parts := strings.Split(clientSecret, "-")
|
|
if len(parts) != 4 {
|
|
return "", fmt.Errorf("secret is not valid")
|
|
}
|
|
return parts[2], nil
|
|
}
|
|
|
|
func upgraderOrInstaller(cfg *action.Configuration, releaseName string) helmInstallerFunc {
|
|
hist := action.NewHistory(cfg)
|
|
hist.Max = 1
|
|
helmVersions, err := hist.Run(releaseName)
|
|
if err == driver.ErrReleaseNotFound || (len(helmVersions) > 0 && helmVersions[0].Info.Status == release.StatusUninstalled) {
|
|
return helmInstaller(cfg, releaseName)
|
|
} else {
|
|
return helmUpgrader(cfg)
|
|
}
|
|
}
|
|
|
|
func helmUpgrader(cfg *action.Configuration) helmInstallerFunc {
|
|
upgrade := action.NewUpgrade(cfg)
|
|
upgrade.Namespace = "tailscale"
|
|
upgrade.Install = true
|
|
upgrade.Wait = true
|
|
upgrade.Timeout = 5 * time.Minute
|
|
return upgrade.RunWithContext
|
|
}
|
|
|
|
func helmInstaller(cfg *action.Configuration, releaseName string) helmInstallerFunc {
|
|
install := action.NewInstall(cfg)
|
|
install.Namespace = "tailscale"
|
|
install.CreateNamespace = true
|
|
install.ReleaseName = releaseName
|
|
install.Wait = true
|
|
install.Timeout = 5 * time.Minute
|
|
install.Replace = true
|
|
return func(ctx context.Context, _ string, chart *chart.Chart, values map[string]any) (*release.Release, error) {
|
|
return install.RunWithContext(ctx, chart, values)
|
|
}
|
|
}
|
|
|
|
type helmInstallerFunc func(context.Context, string, *chart.Chart, map[string]any) (*release.Release, error)
|
|
|
|
// gitRootDir returns the top-level directory of the current git repo. Expects
|
|
// to be run from inside a git repo.
|
|
func gitRootDir() (string, error) {
|
|
top, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to find git top level (not in corp git?): %w", err)
|
|
}
|
|
return strings.TrimSpace(string(top)), nil
|
|
}
|
|
|
|
func tagForRepo(dir string) (string, error) {
|
|
cmd := exec.Command("git", "rev-parse", "--short", "HEAD")
|
|
cmd.Dir = dir
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get latest git tag for repo %q: %w", dir, err)
|
|
}
|
|
tag := strings.TrimSpace(string(out))
|
|
|
|
// If dirty, append an extra random tag to ensure unique image tags.
|
|
cmd = exec.Command("git", "status", "--porcelain")
|
|
cmd.Dir = dir
|
|
out, err = cmd.Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to check git status for repo %q: %w", dir, err)
|
|
}
|
|
if strings.TrimSpace(string(out)) != "" {
|
|
tag += "-" + strings.ToLower(rand.Text())
|
|
}
|
|
|
|
return tag, nil
|
|
}
|
|
|
|
func applyDefaultProxyClass(ctx context.Context, logger *zap.SugaredLogger, cl client.Client) error {
|
|
var env []tsapi.Env
|
|
if *fDevcontrol {
|
|
env = []tsapi.Env{
|
|
{
|
|
Name: "TS_DEBUG_ACME_DIRECTORY_URL",
|
|
Value: "https://pebble:14000/dir",
|
|
},
|
|
}
|
|
}
|
|
pc := &tsapi.ProxyClass{
|
|
TypeMeta: metav1.TypeMeta{
|
|
APIVersion: tsapi.SchemeGroupVersion.String(),
|
|
Kind: tsapi.ProxyClassKind,
|
|
},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "default",
|
|
},
|
|
Spec: tsapi.ProxyClassSpec{
|
|
UseLetsEncryptStagingEnvironment: !*fDevcontrol,
|
|
StatefulSet: &tsapi.StatefulSet{
|
|
Pod: &tsapi.Pod{
|
|
TailscaleInitContainer: &tsapi.Container{
|
|
ImagePullPolicy: "IfNotPresent",
|
|
},
|
|
TailscaleContainer: &tsapi.Container{
|
|
ImagePullPolicy: "IfNotPresent",
|
|
Env: env,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
owner := client.FieldOwner("k8s-test")
|
|
if err := cl.Patch(ctx, pc, client.Apply, owner); err != nil {
|
|
return fmt.Errorf("failed to apply default ProxyClass: %w", err)
|
|
}
|
|
|
|
// Wait for the ProxyClass to be marked ready.
|
|
ctx, cancel := context.WithTimeout(ctx, time.Minute)
|
|
defer cancel()
|
|
for {
|
|
if err := cl.Get(ctx, client.ObjectKeyFromObject(pc), pc); err != nil {
|
|
return fmt.Errorf("failed to get default ProxyClass: %w", err)
|
|
}
|
|
if tsoperator.ProxyClassIsReady(pc) {
|
|
break
|
|
}
|
|
logger.Info("waiting for default ProxyClass to be ready...")
|
|
select {
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("timeout waiting for default ProxyClass to be ready")
|
|
case <-time.After(time.Second):
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// appendPEM joins PEM blobs with a newline separator so a blob lacking a
|
|
// trailing newline doesn't glue its END line to the next BEGIN line.
|
|
// Otherwise curl/OpenSSL can silently drop the later certs.
|
|
// TODO(beckypauley): avoid maintaining both caPEM and testCAs. This can then be removed.
|
|
func appendPEM(dst []byte, blobs ...[]byte) []byte {
|
|
for _, b := range blobs {
|
|
if len(b) == 0 {
|
|
continue
|
|
}
|
|
if len(dst) > 0 && dst[len(dst)-1] != '\n' {
|
|
dst = append(dst, '\n')
|
|
}
|
|
dst = append(dst, b...)
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func deployNameserver(ctx context.Context, logger *zap.SugaredLogger, cl client.Client, img *tsapi.NameserverImage) (*tsapi.DNSConfig, error) {
|
|
dc := &tsapi.DNSConfig{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "dns"},
|
|
Spec: tsapi.DNSConfigSpec{Nameserver: &tsapi.Nameserver{Image: img}},
|
|
}
|
|
if err := createOrUpdate(ctx, cl, dc); err != nil {
|
|
return nil, fmt.Errorf("failed to create DNSConfig: %w", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
|
defer cancel()
|
|
ticker := time.NewTicker(time.Second * 2)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, fmt.Errorf("timeout waiting for nameserver to be ready")
|
|
case <-ticker.C:
|
|
if err := cl.Get(ctx, client.ObjectKeyFromObject(dc), dc); err != nil {
|
|
return nil, fmt.Errorf("failed to get DNSConfig: %w", err)
|
|
}
|
|
if tsoperator.DNSCfgIsReady(dc) && dc.Status.Nameserver != nil && dc.Status.Nameserver.IP != "" {
|
|
logger.Infof("nameserver ready; Service IP %s", dc.Status.Nameserver.IP)
|
|
return dc, nil
|
|
}
|
|
logger.Info("waiting for nameserver to be ready...")
|
|
}
|
|
}
|
|
}
|
|
|
|
func patchClusterDNS(ctx context.Context, logger *zap.SugaredLogger, nameserverIP string) (func(), error) {
|
|
if cm := getDNSConfigMap(ctx, "coredns"); cm != nil && cm.Data["Corefile"] != "" {
|
|
corefile := stripTSNetZone(cm.Data["Corefile"]) + fmt.Sprintf(`
|
|
ts.net:53 {
|
|
errors
|
|
cache 30
|
|
forward . %s
|
|
}
|
|
`, nameserverIP)
|
|
return patchDNSConfigMap(logger, cm, "Corefile", corefile)
|
|
}
|
|
if cm := getDNSConfigMap(ctx, "kube-dns"); cm != nil {
|
|
stub, err := json.Marshal(map[string][]string{"ts.net": {nameserverIP}})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshalling stubDomains: %w", err)
|
|
}
|
|
return patchDNSConfigMap(logger, cm, "stubDomains", string(stub))
|
|
}
|
|
return nil, fmt.Errorf("cluster DNS is not a patchable CoreDNS/kube-dns")
|
|
}
|
|
|
|
func getDNSConfigMap(ctx context.Context, name string) *corev1.ConfigMap {
|
|
cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: "kube-system", Name: name}}
|
|
if err := kubeClient.Get(ctx, client.ObjectKeyFromObject(cm), cm); err != nil {
|
|
return nil
|
|
}
|
|
return cm
|
|
}
|
|
|
|
// patchDNSConfigMap updates the given Configmap and returns a closure
|
|
// for test cleanup.
|
|
func patchDNSConfigMap(logger *zap.SugaredLogger, cm *corev1.ConfigMap, key, value string) (func(), error) {
|
|
orig := maps.Clone(cm.Data)
|
|
if cm.Data == nil {
|
|
cm.Data = map[string]string{}
|
|
}
|
|
cm.Data[key] = value
|
|
if err := kubeClient.Update(context.Background(), cm); err != nil {
|
|
return nil, fmt.Errorf("patching %s %s: %w", cm.Name, key, err)
|
|
}
|
|
logger.Infof("patched %s %s with ts.net entry", cm.Name, key)
|
|
|
|
name := cm.Name
|
|
return func() {
|
|
restore := getDNSConfigMap(context.Background(), name)
|
|
if restore == nil {
|
|
logger.Warnf("restoring %s %s: get failed", name, key)
|
|
return
|
|
}
|
|
restore.Data = orig
|
|
if err := kubeClient.Update(context.Background(), restore); err != nil {
|
|
logger.Warnf("restoring %s %s: %v", name, key, err)
|
|
}
|
|
}, nil
|
|
}
|
|
|
|
// stripTSNetZone removes a previously-appended `ts.net:53 { ... }` server block
|
|
// from a Corefile.
|
|
func stripTSNetZone(corefile string) string {
|
|
idx := strings.Index(corefile, "ts.net:53 {")
|
|
if idx == -1 {
|
|
return corefile
|
|
}
|
|
rest := corefile[idx:]
|
|
end := strings.Index(rest, "\n}")
|
|
if end == -1 {
|
|
return corefile[:idx]
|
|
}
|
|
return corefile[:idx] + rest[end+len("\n}"):]
|
|
}
|
|
|
|
// forwardLocalPortToPod sets up port forwarding to the specified Pod and remote port.
|
|
// It runs until the provided ctx is done.
|
|
func forwardLocalPortToPod(ctx context.Context, logger *zap.SugaredLogger, cfg *rest.Config, ns, podName string, port int) error {
|
|
transport, upgrader, err := spdy.RoundTripperFor(cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create round tripper: %w", err)
|
|
}
|
|
|
|
u, err := url.Parse(fmt.Sprintf("%s%s/api/v1/namespaces/%s/pods/%s/portforward", cfg.Host, cfg.APIPath, ns, podName))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse URL: %w", err)
|
|
}
|
|
dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, "POST", u)
|
|
|
|
stopChan := make(chan struct{}, 1)
|
|
readyChan := make(chan struct{}, 1)
|
|
|
|
ports := []string{fmt.Sprintf("%d:%d", port, port)}
|
|
|
|
// TODO(tomhjp): work out how zap logger can be used instead of stdout/err.
|
|
pf, err := portforward.New(dialer, ports, stopChan, readyChan, os.Stdout, os.Stderr)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create port forwarder: %w", err)
|
|
}
|
|
|
|
go func() {
|
|
if err := pf.ForwardPorts(); err != nil {
|
|
logger.Infof("Port forwarding error: %v\n", err)
|
|
}
|
|
}()
|
|
|
|
var once sync.Once
|
|
go func() {
|
|
<-ctx.Done()
|
|
once.Do(func() { close(stopChan) })
|
|
}()
|
|
|
|
// Wait for port forwarding to be ready
|
|
select {
|
|
case <-readyChan:
|
|
logger.Infof("Port forwarding to Pod %s/%s ready", ns, podName)
|
|
case <-time.After(10 * time.Second):
|
|
once.Do(func() { close(stopChan) })
|
|
return fmt.Errorf("timeout waiting for port forward to be ready")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// waitForPodReady waits for at least 1 Pod matching the label selector to be
|
|
// in Ready state. It returns the name of the first ready Pod it finds.
|
|
func waitForPodReady(ctx context.Context, logger *zap.SugaredLogger, cl client.WithWatch, ns string, labelSelector client.MatchingLabels) (string, error) {
|
|
pods := &corev1.PodList{}
|
|
w, err := cl.Watch(ctx, pods, client.InNamespace(ns), client.MatchingLabels(labelSelector))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create pod watcher: %v", err)
|
|
}
|
|
defer w.Stop()
|
|
|
|
for {
|
|
select {
|
|
case event, ok := <-w.ResultChan():
|
|
if !ok {
|
|
return "", fmt.Errorf("watcher channel closed")
|
|
}
|
|
|
|
switch event.Type {
|
|
case watch.Added, watch.Modified:
|
|
if pod, ok := event.Object.(*corev1.Pod); ok {
|
|
for _, condition := range pod.Status.Conditions {
|
|
if condition.Type == corev1.PodReady && condition.Status == corev1.ConditionTrue {
|
|
logger.Infof("pod %s is ready", pod.Name)
|
|
return pod.Name, nil
|
|
}
|
|
}
|
|
}
|
|
case watch.Error:
|
|
return "", fmt.Errorf("watch error: %v", event.Object)
|
|
}
|
|
case <-ctx.Done():
|
|
return "", fmt.Errorf("timeout waiting for pod to be ready")
|
|
}
|
|
}
|
|
}
|
|
|
|
func pebbleGet(ctx context.Context, port uint16, path string) ([]byte, error) {
|
|
pebbleClient := &http.Client{
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{
|
|
RootCAs: testCAs,
|
|
},
|
|
},
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://localhost:%d%s", port, path), nil)
|
|
resp, err := pebbleClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch pebble root CA: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("HTTP %d when fetching pebble root CA", resp.StatusCode)
|
|
}
|
|
b, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read pebble root CA response: %w", err)
|
|
}
|
|
|
|
return b, nil
|
|
}
|
|
|
|
func buildImage(ctx context.Context, dir, repo, target, tag, arch, baseImage string, extraCACerts []string) error {
|
|
var files []string
|
|
for _, f := range extraCACerts {
|
|
files = append(files, fmt.Sprintf("%s:/etc/ssl/certs/%s", f, filepath.Base(f)))
|
|
}
|
|
// Build only for the specified platform (to reduce build time),
|
|
// otherwise default to build all platforms.
|
|
var platform string
|
|
switch arch {
|
|
case "":
|
|
platform = "local"
|
|
case "amd64":
|
|
platform = "flyio"
|
|
default:
|
|
platform = ""
|
|
}
|
|
args := []string{target,
|
|
fmt.Sprintf("PLATFORM=%s", platform),
|
|
fmt.Sprintf("TAGS=%s", tag),
|
|
fmt.Sprintf("REPO=%s", repo),
|
|
fmt.Sprintf("FILES=%s", strings.Join(files, ",")),
|
|
}
|
|
if baseImage != "" {
|
|
// make exports command line variables to recipes, so this reaches
|
|
// build_docker.sh as the BASE env var.
|
|
args = append(args, fmt.Sprintf("BASE=%s", baseImage))
|
|
}
|
|
cmd := exec.CommandContext(ctx, "make", args...)
|
|
cmd.Dir = dir
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("failed to build image %q: %w", target, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func imageRepo(name string) string {
|
|
if *fRegistry != "" {
|
|
return strings.TrimSuffix(*fRegistry, "/") + "/" + name
|
|
}
|
|
return "local/" + name
|
|
}
|
|
|
|
// detectNodeArch returns the CPU architecture of the cluster's nodes.
|
|
// It uses the first node found. Mixed-architecture clusters are not
|
|
// supported.
|
|
func detectNodeArch(ctx context.Context, cl client.Client) (string, error) {
|
|
var nodes corev1.NodeList
|
|
if err := cl.List(ctx, &nodes); err != nil {
|
|
return "", fmt.Errorf("listing nodes: %w", err)
|
|
}
|
|
if len(nodes.Items) == 0 {
|
|
return "", fmt.Errorf("cluster has no nodes")
|
|
}
|
|
arch := nodes.Items[0].Status.NodeInfo.Architecture
|
|
if arch == "" {
|
|
return "", fmt.Errorf("node %q reports no architecture", nodes.Items[0].Name)
|
|
}
|
|
return arch, nil
|
|
}
|
|
|
|
func createOrUpdate(ctx context.Context, cl client.Client, obj client.Object) error {
|
|
if err := cl.Create(ctx, obj); err != nil {
|
|
if !apierrors.IsAlreadyExists(err) {
|
|
return err
|
|
}
|
|
return cl.Update(ctx, obj)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// detectClusterIPFamilies determines which IP families the cluster supports by
|
|
// creating a throwaway ClusterIP Service with PreferDualStack and reading back
|
|
// the IP families the API server assigns.
|
|
func detectClusterIPFamilies(ctx context.Context, logger *zap.SugaredLogger, cl client.Client) error {
|
|
svc := &corev1.Service{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "ipfamily-probe",
|
|
Namespace: ns,
|
|
},
|
|
Spec: corev1.ServiceSpec{
|
|
Type: corev1.ServiceTypeClusterIP,
|
|
IPFamilyPolicy: new(corev1.IPFamilyPolicyPreferDualStack),
|
|
Ports: []corev1.ServicePort{
|
|
{Name: "probe", Protocol: corev1.ProtocolTCP, Port: 80},
|
|
},
|
|
},
|
|
}
|
|
if err := cl.Create(ctx, svc); err != nil {
|
|
return fmt.Errorf("failed to create IP family Service: %w", err)
|
|
}
|
|
defer func() {
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
if err := cl.Delete(ctx, svc); err != nil {
|
|
logger.Warnf("failed to clean up IP family Service %s/%s: %v", svc.Namespace, svc.Name, err)
|
|
}
|
|
}()
|
|
|
|
for _, ip := range svc.Spec.IPFamilies {
|
|
switch ip {
|
|
case corev1.IPv4Protocol:
|
|
clusterIPv4Support = true
|
|
case corev1.IPv6Protocol:
|
|
clusterIPv6Support = true
|
|
}
|
|
}
|
|
if !clusterIPv4Support && !clusterIPv6Support {
|
|
return fmt.Errorf("Service %s/%s reported no IP families", svc.Namespace, svc.Name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// createTailnet creates a new tailnet and returns a tailscale.Client
|
|
// authenticated against it using the bootstrap credentials included in the
|
|
// creation response.
|
|
func createTailnet(ctx context.Context, tsClient *tailscale.Client) (*tailscale.Client, error) {
|
|
tailnetName := fmt.Sprintf("second-tailnet-%d", time.Now().Unix())
|
|
body, err := json.Marshal(map[string]any{"displayName": tailnetName})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal tailnet creation request: %w", err)
|
|
}
|
|
// TODO(beckypauley): change to use a method on tailscale.Client once this is available.
|
|
req, _ := http.NewRequestWithContext(ctx, "POST", tsClient.BaseURL.String()+"/api/v2/organizations/-/tailnets", bytes.NewBuffer(body))
|
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", tsClient.APIKey))
|
|
resp, err := tsClient.HTTP.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create tailnet: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("HTTP %d creating tailnet: %s", resp.StatusCode, string(b))
|
|
}
|
|
var result struct {
|
|
OauthClient struct {
|
|
ID string `json:"id"`
|
|
Secret string `json:"secret"`
|
|
} `json:"oauthClient"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
return tailscaleClientFromSecret(ctx, tsClient.BaseURL.String(), result.OauthClient.ID, result.OauthClient.Secret)
|
|
}
|
|
|
|
// tailscaleClientFromSecret exchanges OAuth client credentials for an access token and
|
|
// returns a tailscale.Client configured to use it. The token is valid for
|
|
// one hour, which is sufficient for the tests to run. No need for refresh logic.
|
|
func tailscaleClientFromSecret(ctx context.Context, baseURL, clientID, clientSecret string) (*tailscale.Client, error) {
|
|
cfg := clientcredentials.Config{
|
|
ClientID: clientID,
|
|
ClientSecret: clientSecret,
|
|
TokenURL: fmt.Sprintf("%s/api/v2/oauth/token", baseURL),
|
|
}
|
|
tk, err := cfg.Token(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get OAuth token for client %q: %w", clientID, err)
|
|
}
|
|
return &tailscale.Client{
|
|
APIKey: tk.AccessToken,
|
|
BaseURL: must.Get(url.Parse(baseURL)),
|
|
}, nil
|
|
}
|