diff --git a/cmd/root.go b/cmd/root.go index 92b671a..7ac053b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" "github.com/aifoundry-org/oxide-controller/pkg/cluster" + "github.com/aifoundry-org/oxide-controller/pkg/config" logpkg "github.com/aifoundry-org/oxide-controller/pkg/log" oxidepkg "github.com/aifoundry-org/oxide-controller/pkg/oxide" "github.com/aifoundry-org/oxide-controller/pkg/server" @@ -163,11 +164,6 @@ func rootCmd() (*cobra.Command, error) { oxideToken = profileToken } - oxideConfig := &oxide.Config{ - Host: oxideAPIURL, - Token: string(oxideToken), - } - if strings.HasPrefix(oxideToken, "file:") { tokenFilePath := strings.TrimPrefix(oxideToken, "file:") oxideToken = "" @@ -216,18 +212,56 @@ func rootCmd() (*cobra.Command, error) { cmd.SilenceUsage = true ctx := context.Background() - - c := cluster.New(logentry, oxideConfig, clusterProject, - controlPlanePrefix, workerPrefix, int(controlPlaneCount), int(workerCount), - cluster.NodeSpec{Image: cluster.Image{Name: controlPlaneImageName, Source: controlPlaneImageSource, Blocksize: controlPlaneImageBlocksize}, MemoryGB: int(controlPlaneMemory), CPUCount: int(controlPlaneCPU), ExternalIP: controlPlaneExternalIP, RootDiskSize: int(controlPlaneRootDiskSizeGB * cluster.GB), ExtraDiskSize: int(controlPlaneExtraDiskSizeGB * cluster.GB), TailscaleAuthKey: tailscaleAuthKey, TailscaleTag: tailscaleTag}, - cluster.NodeSpec{Image: cluster.Image{Name: workerImageName, Source: workerImageSource, Blocksize: workerImageBlocksize}, MemoryGB: int(workerMemory), CPUCount: int(workerCPU), ExternalIP: workerExternalIP, RootDiskSize: int(workerRootDiskSizeGB * cluster.GB), ExtraDiskSize: int(workerExtraDiskSizeGB * cluster.GB), TailscaleAuthKey: tailscaleAuthKey, TailscaleTag: tailscaleTag}, - imageParallelism, - controlPlaneNamespace, controlPlaneSecret, pubkey, - time.Duration(clusterInitWait)*time.Minute, + controllerConfig := &config.ControllerConfig{ + UserSSHPublicKey: string(pubkey), + OxideToken: oxideToken, + OxideURL: oxideAPIURL, + ClusterProject: clusterProject, + ControlPlaneCount: controlPlaneCount, + ControlPlaneSpec: config.NodeSpec{ + Image: config.Image{Name: controlPlaneImageName, Source: controlPlaneImageSource, Blocksize: controlPlaneImageBlocksize}, + Prefix: controlPlanePrefix, + MemoryGB: int(controlPlaneMemory), + CPUCount: int(controlPlaneCPU), + ExternalIP: controlPlaneExternalIP, + RootDiskSize: int(controlPlaneRootDiskSizeGB * cluster.GB), + ExtraDiskSize: int(controlPlaneExtraDiskSizeGB * cluster.GB), + TailscaleAuthKey: tailscaleAuthKey, + TailscaleTag: tailscaleTag, + }, + WorkerCount: workerCount, + WorkerSpec: config.NodeSpec{ + Image: config.Image{Name: workerImageName, Source: workerImageSource, Blocksize: workerImageBlocksize}, + Prefix: workerPrefix, + MemoryGB: int(workerMemory), + CPUCount: int(workerCPU), + ExternalIP: workerExternalIP, + RootDiskSize: int(workerRootDiskSizeGB * cluster.GB), + ExtraDiskSize: int(workerExtraDiskSizeGB * cluster.GB), + TailscaleAuthKey: tailscaleAuthKey, + TailscaleTag: tailscaleTag, + }, + + ControlPlaneNamespace: controlPlaneNamespace, + SecretName: controlPlaneSecret, + Address: address, + ControlLoopMins: controlLoopMins, + ImageParallelism: imageParallelism, + TailscaleAuthKey: tailscaleAuthKey, + TailscaleAPIKey: tailscaleAPIKey, + TailscaleTag: tailscaleTag, + TailscaleTailnet: tailscaleTailnet, + } + c := cluster.New( + logentry, + controllerConfig, kubeconfigOverwrite, - tailscaleAPIKey, - tailscaleTailnet, controllerOCIImage, + time.Duration(clusterInitWait)*time.Minute, + + /* + pubkey, + */ ) // we perform 2 execution loops of the cluster execute function: // - the first one is to create the cluster and get the kubeconfig diff --git a/pkg/cluster/cluster.go b/pkg/cluster/cluster.go index 1fc5ff5..0b24cd0 100644 --- a/pkg/cluster/cluster.go +++ b/pkg/cluster/cluster.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/aifoundry-org/oxide-controller/pkg/config" "github.com/aifoundry-org/oxide-controller/pkg/util" "k8s.io/client-go/kubernetes" "tailscale.com/client/tailscale/v2" @@ -18,51 +19,36 @@ import ( ) type Cluster struct { - logger *log.Entry - oxideConfig *oxide.Config - projectID string - controlPlanePrefix string - workerPrefix string - controlPlaneCount int - clusterInitWait time.Duration + // logger + logger *log.Entry + + // reusable config that should be loaded into the secret and shared, whether running locally or in-cluster + config *config.ControllerConfig + + // config that is derived locally kubeconfigOverwrite bool - // workerCount per the CLI flags; once cluster is up and running, relies solely on amount stored in secret - workerCount int - controlPlaneSpec, workerSpec NodeSpec - secretName string - namespace string - userPubkey []byte - controlPlaneIP string - imageParallelism int - tailscaleAPIKey string - tailscaleTailnet string - clientset *kubernetes.Clientset - apiConfig *Config - ociImage string + ociImage string // OCI image to use for the controller + oxideConfig *oxide.Config + clientset *kubernetes.Clientset + apiConfig *Config + projectID string // ID of the Oxide project + initWait time.Duration // time to wait for the cluster to initialize } // New creates a new Cluster instance -func New(logger *log.Entry, oxideConfig *oxide.Config, projectID string, controlPlanePrefix, workerPrefix string, controlPlaneCount, workerCount int, controlPlaneSpec, workerSpec NodeSpec, imageParallelism int, namespace, secretName string, pubkey []byte, clusterInitWait time.Duration, kubeconfigOverwrite bool, tailscaleAPIKey, tailscaleTailnet, OCIimage string) *Cluster { +func New(logger *log.Entry, ctrlrConfig *config.ControllerConfig, kubeconfigOverwrite bool, ociImage string, initWait time.Duration) *Cluster { + //oxideConfig *oxide.Config, projectID string, controlPlanePrefix, workerPrefix string, controlPlaneCount, workerCount int, controlPlaneSpec, workerSpec NodeSpec, imageParallelism int, namespace, secretName string, pubkey []byte, clusterInitWait time.Duration, kubeconfigOverwrite bool, tailscaleAPIKey, tailscaleTailnet, OCIimage string) c := &Cluster{ - logger: logger.WithField("component", "cluster"), - oxideConfig: oxideConfig, - projectID: projectID, - controlPlanePrefix: controlPlanePrefix, - workerPrefix: workerPrefix, - controlPlaneSpec: controlPlaneSpec, - workerSpec: workerSpec, - secretName: secretName, - namespace: namespace, - userPubkey: pubkey, - clusterInitWait: clusterInitWait, + logger: logger.WithField("component", "cluster"), + config: ctrlrConfig, + oxideConfig: &oxide.Config{ + Token: ctrlrConfig.OxideToken, + Host: ctrlrConfig.OxideURL, + }, kubeconfigOverwrite: kubeconfigOverwrite, - imageParallelism: imageParallelism, - tailscaleAPIKey: tailscaleAPIKey, - tailscaleTailnet: tailscaleTailnet, - ociImage: OCIimage, + ociImage: ociImage, + initWait: initWait, } - c.workerCount = workerCount - c.controlPlaneCount = controlPlaneCount return c } @@ -74,9 +60,9 @@ func (c *Cluster) ensureClusterExists(ctx context.Context) (newKubeconfig []byte return nil, fmt.Errorf("failed to create Oxide API client: %v", err) } projectID := c.projectID - controlPlanePrefix := c.controlPlanePrefix - controlPlaneCount := c.controlPlaneCount - secretName := c.secretName + controlPlanePrefix := c.config.ControlPlaneSpec.Prefix + controlPlaneCount := c.config.ControlPlaneCount + secretName := c.config.SecretName c.logger.Debugf("Checking if control plane IP %s exists", controlPlanePrefix) controlPlaneIP, err := c.ensureControlPlaneIP(ctx, controlPlanePrefix) @@ -84,8 +70,8 @@ func (c *Cluster) ensureClusterExists(ctx context.Context) (newKubeconfig []byte return nil, fmt.Errorf("failed to get control plane IP: %w", err) } - if c.controlPlaneIP == "" { - c.controlPlaneIP = controlPlaneIP.Ip + if c.config.ControlPlaneIP == "" { + c.config.ControlPlaneIP = controlPlaneIP.Ip } c.logger.Debugf("Checking if %d control plane nodes exist with prefix %s", controlPlaneCount, controlPlanePrefix) @@ -146,8 +132,8 @@ func (c *Cluster) ensureClusterExists(ctx context.Context) (newKubeconfig []byte return nil, fmt.Errorf("failed to generate SSH key pair: %w", err) } var pubkeyList []string - if c.userPubkey != nil { - pubkeyList = append(pubkeyList, string(c.userPubkey)) + if c.config.UserSSHPublicKey != "" { + pubkeyList = append(pubkeyList, c.config.UserSSHPublicKey) } pubkeyList = append(pubkeyList, string(pub)) // add the public key to the node in addition to the user one @@ -166,7 +152,7 @@ func (c *Cluster) ensureClusterExists(ctx context.Context) (newKubeconfig []byte externalIP string fipAttached bool ) - if c.controlPlaneSpec.ExternalIP { + if c.config.ControlPlaneSpec.ExternalIP { c.logger.Debugf("Control plane node %s has external IP, using that", hostid) ipList, err := client.InstanceExternalIpList(ctx, oxide.InstanceExternalIpListParams{ Instance: oxide.NameOrId(hostid), @@ -215,18 +201,18 @@ func (c *Cluster) ensureClusterExists(ctx context.Context) (newKubeconfig []byte clusterAccessIP := externalIP // wait for the control plane node to be up and running - timeLeft := c.clusterInitWait + timeLeft := c.initWait for { c.logger.Infof("Waiting %s for control plane node to be up and running...", timeLeft) sleepTime := 30 * time.Second time.Sleep(sleepTime) timeLeft -= sleepTime - if c.tailscaleAPIKey != "" { + if c.config.TailscaleAPIKey != "" { c.logger.Infof("Checking if control plane node has joined tailnet") client := &tailscale.Client{ - Tailnet: c.tailscaleTailnet, - APIKey: c.tailscaleAPIKey, + Tailnet: c.config.TailscaleTailnet, + APIKey: c.config.TailscaleAPIKey, } ctx := context.Background() devices, err := client.Devices().List(ctx) @@ -286,16 +272,9 @@ func (c *Cluster) ensureClusterExists(ctx context.Context) (newKubeconfig []byte return nil, fmt.Errorf("failed to run command to retrieve join token on control plane node: %w", err) } // save the private key and public key to the secret - secrets[secretKeySystemSSHPublic] = pub - secrets[secretKeySystemSSHPrivate] = priv - secrets[secretKeyJoinToken] = joinToken - secrets[secretKeyOxideToken] = []byte(c.oxideConfig.Token) - secrets[secretKeyOxideURL] = []byte(c.oxideConfig.Host) - - // save the user ssh public key to the secrets map - if c.userPubkey != nil { - secrets[secretKeyUserSSH] = c.userPubkey - } + c.config.K3sJoinToken = string(joinToken) + c.config.SystemSSHPublicKey = string(pub) + c.config.SystemSSHPrivateKey = string(priv) // get the kubeconfig kubeconfig, err := util.RunSSHCommand("root", fmt.Sprintf("%s:22", clusterAccessIP), priv, "cat /etc/rancher/k3s/k3s.yaml") @@ -310,10 +289,6 @@ func (c *Cluster) ensureClusterExists(ctx context.Context) (newKubeconfig []byte re := regexp.MustCompile(`(server:\s*\w+://)(\d+\.\d+\.\d+\.\d+)(:\d+)`) kubeconfigString = re.ReplaceAllString(kubeconfigString, fmt.Sprintf("${1}%s${3}", clusterAccessIP)) - // if we have worker node count explicitly defined, save it - if c.workerCount > 0 { - secrets[secretKeyWorkerCount] = []byte(fmt.Sprintf("%d", c.workerCount)) - } newKubeconfig = []byte(kubeconfigString) // get a Kubernetes client @@ -329,12 +304,18 @@ func (c *Cluster) ensureClusterExists(ctx context.Context) (newKubeconfig []byte c.clientset = clientset // ensure we have the namespace we need - namespace := c.namespace + namespace := c.config.ControlPlaneNamespace if err := createNamespace(ctx, clientset, namespace); err != nil { return nil, fmt.Errorf("failed to create namespace: %w", err) } - // save the join token, system ssh key pair, user ssh key to the Kubernetes secret + configJson, err := c.config.ToJSON() + if err != nil { + return nil, fmt.Errorf("failed to convert config to JSON: %w", err) + } + secrets[secretKeyConfig] = configJson + + // save the config to the Kubernetes secret c.logger.Debugf("Saving secret %s/%s to Kubernetes", namespace, secretName) if err := saveSecret(ctx, clientset, c.logger, namespace, secretName, secrets); err != nil { return nil, fmt.Errorf("failed to save secret: %w", err) diff --git a/pkg/cluster/const.go b/pkg/cluster/const.go index 0edc35f..53422cf 100644 --- a/pkg/cluster/const.go +++ b/pkg/cluster/const.go @@ -7,14 +7,9 @@ const ( blockSize = 4096 - secretKeyUserSSH = "user-ssh-public-key" - secretKeyJoinToken = "k3s-join-token" - secretKeySystemSSHPublic = "system-ssh-public-key" - secretKeySystemSSHPrivate = "system-ssh-private-key" - secretKeyWorkerCount = "worker-count" - secretKeyOxideToken = "oxide-token" - secretKeyOxideURL = "oxide-url" - maximumChunkSize = 512 * KB + secretKeyConfig = "config" + + maximumChunkSize = 512 * KB devModeOCIImage = "dev" utilityImageName = "alpine:3.21" diff --git a/pkg/cluster/copy.go b/pkg/cluster/copy.go index ce18571..26ca9ca 100644 --- a/pkg/cluster/copy.go +++ b/pkg/cluster/copy.go @@ -33,13 +33,13 @@ func (c *Cluster) LoadControllerToClusterNodes(ctx context.Context, infile io.Re labelKey := "app" labelValue := "preload-binary" labels := map[string]string{labelKey: labelValue} - if err := deployPreloadBinaryDaemonSet(c.clientset, c.namespace, preloadBinaryName, labels); err != nil { + if err := deployPreloadBinaryDaemonSet(c.clientset, c.config.ControlPlaneNamespace, preloadBinaryName, labels); err != nil { return fmt.Errorf("deploying preload-binary DaemonSet: %w", err) } - if err := copyToAllDaemonSetPods(c.clientset, c.apiConfig.Config, c.namespace, fmt.Sprintf("%s=%s", labelKey, labelValue), "writer", filepath.Join(containerDir, binaryName), infile); err != nil { + if err := copyToAllDaemonSetPods(c.clientset, c.apiConfig.Config, c.config.ControlPlaneNamespace, fmt.Sprintf("%s=%s", labelKey, labelValue), "writer", filepath.Join(containerDir, binaryName), infile); err != nil { return fmt.Errorf("copying to all DaemonSet pods: %w", err) } - if err := removePreloadBinaryDaemonSet(c.clientset, c.namespace, preloadBinaryName); err != nil { + if err := removePreloadBinaryDaemonSet(c.clientset, c.config.ControlPlaneNamespace, preloadBinaryName); err != nil { return fmt.Errorf("removing preload-binary DaemonSet: %w", err) } // update our OCI image to point to the new image diff --git a/pkg/cluster/exec.go b/pkg/cluster/exec.go index ee2603f..8ea9791 100644 --- a/pkg/cluster/exec.go +++ b/pkg/cluster/exec.go @@ -20,16 +20,16 @@ func (c *Cluster) Execute(ctx context.Context) (newKubeconfig []byte, err error) return nil, fmt.Errorf("failed to create Oxide API client: %v", err) } - projectID, err := ensureProjectExists(ctx, c.logger, client, c.projectID) + projectID, err := ensureProjectExists(ctx, c.logger, client, c.config.ClusterProject) if err != nil { return nil, fmt.Errorf("project verification failed: %v", err) } - if projectID != "" && projectID != c.projectID { + if projectID != "" && projectID != c.config.ClusterProject { c.projectID = projectID c.logger.Infof("Using project ID: %s", c.projectID) } - images, err := ensureImagesExist(ctx, c.logger, client, c.projectID, c.imageParallelism, c.controlPlaneSpec.Image, c.workerSpec.Image) + images, err := ensureImagesExist(ctx, c.logger, client, c.projectID, c.config.ImageParallelism, c.config.ControlPlaneSpec.Image, c.config.WorkerSpec.Image) if err != nil { return nil, fmt.Errorf("image verification failed: %v", err) } @@ -39,23 +39,23 @@ func (c *Cluster) Execute(ctx context.Context) (newKubeconfig []byte, err error) c.logger.Infof("images %v", images) // control plane image and root disk size - c.controlPlaneSpec.Image = images[0] - minSize := util.RoundUp(c.controlPlaneSpec.Image.Size, GB) - if c.controlPlaneSpec.RootDiskSize == 0 { - c.controlPlaneSpec.RootDiskSize = minSize + c.config.ControlPlaneSpec.Image = images[0] + minSize := util.RoundUp(c.config.ControlPlaneSpec.Image.Size, GB) + if c.config.ControlPlaneSpec.RootDiskSize == 0 { + c.config.ControlPlaneSpec.RootDiskSize = minSize } - if c.controlPlaneSpec.RootDiskSize < minSize { - return nil, fmt.Errorf("control plane root disk size %d is less than minimum image size %d", c.controlPlaneSpec.RootDiskSize, minSize) + if c.config.ControlPlaneSpec.RootDiskSize < minSize { + return nil, fmt.Errorf("control plane root disk size %d is less than minimum image size %d", c.config.ControlPlaneSpec.RootDiskSize, minSize) } // worker image and root disk size - c.workerSpec.Image = images[1] - minSize = util.RoundUp(c.workerSpec.Image.Size, GB) - if c.workerSpec.RootDiskSize == 0 { - c.workerSpec.RootDiskSize = minSize + c.config.WorkerSpec.Image = images[1] + minSize = util.RoundUp(c.config.WorkerSpec.Image.Size, GB) + if c.config.WorkerSpec.RootDiskSize == 0 { + c.config.WorkerSpec.RootDiskSize = minSize } - if c.workerSpec.RootDiskSize < minSize { - return nil, fmt.Errorf("worker root disk size %d is less than minimum image size %d", c.workerSpec.RootDiskSize, minSize) + if c.config.WorkerSpec.RootDiskSize < minSize { + return nil, fmt.Errorf("worker root disk size %d is less than minimum image size %d", c.config.WorkerSpec.RootDiskSize, minSize) } newKubeconfig, err = c.ensureClusterExists(ctx) diff --git a/pkg/cluster/helm.go b/pkg/cluster/helm.go index 8a6084b..bbba6e4 100644 --- a/pkg/cluster/helm.go +++ b/pkg/cluster/helm.go @@ -53,8 +53,8 @@ func (c *Cluster) LoadHelmCharts(ctx context.Context) error { values := map[string]interface{}{ "createNamespace": false, - "namespace": c.namespace, - "secretName": c.secretName, + "namespace": c.config.ControlPlaneNamespace, + "secretName": c.config.SecretName, "useHostBinary": useHostBinary, "verbose": logpkg.GetFlag(c.logger.Logger.Level), // set logging in the controller to the same as our level "image": map[string]interface{}{ @@ -67,7 +67,7 @@ func (c *Cluster) LoadHelmCharts(ctx context.Context) error { if err != nil { return fmt.Errorf("failed to load chart files: %w", err) } - rel, err := installOrUpgradeEmbeddedChart(chartFiles, c.namespace, c.apiConfig.Config, values) + rel, err := installOrUpgradeEmbeddedChart(chartFiles, c.config.ControlPlaneNamespace, c.apiConfig.Config, values) if err != nil { return fmt.Errorf("failed to install/upgrade helm chart: %w", err) } diff --git a/pkg/cluster/images.go b/pkg/cluster/images.go index b522ba1..016ca28 100644 --- a/pkg/cluster/images.go +++ b/pkg/cluster/images.go @@ -10,6 +10,7 @@ import ( "golang.org/x/sync/errgroup" + "github.com/aifoundry-org/oxide-controller/pkg/config" "github.com/aifoundry-org/oxide-controller/pkg/util" "github.com/oxidecomputer/oxide.go/oxide" log "github.com/sirupsen/logrus" @@ -24,7 +25,7 @@ const ( // will be created at the project level. // The returned images will have their IDs set. It will have the exact same number of images as in the argument images. // This is not a member function of Cluster, as it can be self-contained and therefore tested. -func ensureImagesExist(ctx context.Context, logger *log.Entry, client *oxide.Client, projectID string, parallelism int, images ...Image) ([]Image, error) { +func ensureImagesExist(ctx context.Context, logger *log.Entry, client *oxide.Client, projectID string, parallelism int, images ...config.Image) ([]config.Image, error) { // TODO: We don't need to list images, we can `View` them by name - // `images` array is never long, few more requests shouldn't harm. // TODO: Do we need pagination? Using arbitrary limit for now. @@ -41,9 +42,9 @@ func ensureImagesExist(ctx context.Context, logger *log.Entry, client *oxide.Cli } logger.Debugf("total global images %d", len(globalImages)) var ( - missingImages []Image - uniqueImages []Image - targetImagesMap = make(map[string]Image) + missingImages []config.Image + uniqueImages []config.Image + targetImagesMap = make(map[string]config.Image) projectImageMap = make(map[string]*oxide.Image) globalImageMap = make(map[string]*oxide.Image) imageMap = make(map[string]*oxide.Image) diff --git a/pkg/cluster/node.go b/pkg/cluster/node.go index ae4b3ce..7097fd3 100644 --- a/pkg/cluster/node.go +++ b/pkg/cluster/node.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/aifoundry-org/oxide-controller/pkg/config" "github.com/aifoundry-org/oxide-controller/pkg/util" "github.com/google/uuid" "github.com/oxidecomputer/oxide.go/oxide" @@ -23,7 +24,7 @@ const ( extraMount = "/data" ) -func CreateInstance(ctx context.Context, client *oxide.Client, projectID, instanceName string, spec NodeSpec, cloudConfig string) (*oxide.Instance, error) { +func CreateInstance(ctx context.Context, client *oxide.Client, projectID, instanceName string, spec config.NodeSpec, cloudConfig string) (*oxide.Instance, error) { // every disk needs a unique name. Unfortunately, due to a bug in how // the disks are provided a controller, if the first 20 characters are the same // then you end up with controller conflicts. @@ -146,6 +147,17 @@ func GenerateCloudConfig(nodeType string, initCluster bool, controlPlaneIP, join }) } + k3sRunCmd := []string{ + `PRIVATE_IP=$(hostname -I | awk '{print $1}')`, + `[ -n "${PRIVATE_IP}" ] && K3S_PRIVATE_IP_ARG="--tls-san ${PRIVATE_IP}"`, + `echo "Private IP: ${PRIVATE_IP}"`, + `echo "Private IP k3s arg: ${K3S_PRIVATE_IP_ARG}"`, + `PUBLIC_IP=$(curl -s https://ifconfig.me)`, + `[ -n "${PUBLIC_IP}" ] && K3S_PUBLIC_IP_ARG="--tls-san ${PUBLIC_IP}"`, + `echo "Public IP: ${PUBLIC_IP}"`, + `echo "Public IP k3s arg: ${K3S_PUBLIC_IP_ARG}"`, + } + // k3s switch nodeType { case "server": @@ -158,7 +170,7 @@ func GenerateCloudConfig(nodeType string, initCluster bool, controlPlaneIP, join k3sArgs = append(k3sArgs, fmt.Sprintf("--server https://%s:%d", controlPlaneIP, port)) k3sArgs = append(k3sArgs, fmt.Sprintf("--token %s", joinToken)) } - k3sArgs = append(k3sArgs, "--tls-san ${PRIVATE_IP} --tls-san ${PUBLIC_IP}") + k3sArgs = append(k3sArgs, "${K3S_PRIVATE_IP_ARG} ${K3S_PUBLIC_IP_ARG}") // add the tailscale IP if provided if tailscaleAuthKey != "" { k3sArgs = append(k3sArgs, "--tls-san ${TAILSCALE_IP}") @@ -170,11 +182,8 @@ func GenerateCloudConfig(nodeType string, initCluster bool, controlPlaneIP, join default: return nil, fmt.Errorf("unknown node type: %s", nodeType) } - cfg.RunCmd = append(cfg.RunCmd, []string{ - "PRIVATE_IP=$(hostname -I | awk '{print $1}')", - "PUBLIC_IP=$(curl -s https://ifconfig.me)", - fmt.Sprintf("curl -sfL https://get.k3s.io | sh -s - %s", strings.Join(k3sArgs, " ")), - }) + k3sRunCmd = append(k3sRunCmd, fmt.Sprintf("curl -sfL https://get.k3s.io | sh -s - %s", strings.Join(k3sArgs, " "))) + cfg.RunCmd = append(cfg.RunCmd, k3sRunCmd) // encode var buf bytes.Buffer @@ -197,7 +206,7 @@ func (c *Cluster) CreateControlPlaneNodes(ctx context.Context, initCluster bool, return nil, fmt.Errorf("failed to create Oxide API client: %v", err) } var controlPlaneNodes []oxide.Instance - c.logger.Debugf("Creating %d control plane nodes with prefix %s", count, c.controlPlanePrefix) + c.logger.Debugf("Creating %d control plane nodes with prefix %s", count, c.config.ControlPlaneSpec.Prefix) var joinToken string var pubkey []byte @@ -222,22 +231,22 @@ func (c *Cluster) CreateControlPlaneNodes(ctx context.Context, initCluster bool, pubKeyList = append(pubKeyList, additionalPubKeys...) } var extraNodeDisk string - if c.controlPlaneSpec.ExtraDiskSize > 0 { + if c.config.ControlPlaneSpec.ExtraDiskSize > 0 { extraNodeDisk = extraDisk } - cloudConfig, err := GenerateCloudConfigB64("server", initCluster, c.controlPlaneIP, joinToken, pubKeyList, extraNodeDisk, c.controlPlaneSpec.TailscaleAuthKey, c.controlPlaneSpec.TailscaleTag) + cloudConfig, err := GenerateCloudConfigB64("server", initCluster, c.config.ControlPlaneIP, joinToken, pubKeyList, extraNodeDisk, c.config.ControlPlaneSpec.TailscaleAuthKey, c.config.ControlPlaneSpec.TailscaleTag) if err != nil { return nil, fmt.Errorf("failed to generate cloud config: %w", err) } for i := start; i < start+count; i++ { - instance, err := CreateInstance(ctx, client, c.projectID, fmt.Sprintf("%s%d", c.controlPlanePrefix, i), c.controlPlaneSpec, cloudConfig) + instance, err := CreateInstance(ctx, client, c.projectID, fmt.Sprintf("%s%d", c.config.ControlPlaneSpec.Prefix, i), c.config.ControlPlaneSpec, cloudConfig) if err != nil { return nil, fmt.Errorf("failed to create control plane node: %w", err) } controlPlaneNodes = append(controlPlaneNodes, *instance) } - c.logger.Debugf("Created %d control plane nodes with prefix %s", count, c.controlPlanePrefix) + c.logger.Debugf("Created %d control plane nodes with prefix %s", count, c.config.ControlPlaneSpec.Prefix) return controlPlaneNodes, nil } @@ -256,7 +265,7 @@ func (c *Cluster) EnsureWorkerNodes(ctx context.Context) ([]oxide.Instance, erro return nil, fmt.Errorf("failed to get worker count: %w", err) } c.logger.Debugf("Failed to get worker count from cluster, using CLI flag value and storing") - count = c.workerCount + count = int(c.config.WorkerCount) if err := c.SetWorkerCount(ctx, count); err != nil { return nil, fmt.Errorf("failed to set worker count: %w", err) } @@ -265,7 +274,7 @@ func (c *Cluster) EnsureWorkerNodes(ctx context.Context) ([]oxide.Instance, erro c.logger.Debugf("Ensuring %d worker nodes", count) var nodes []oxide.Instance // first check how many worker nodes we have, by asking the cluster - _, workers, err := getNodesOxide(ctx, c.logger, client, c.projectID, c.controlPlanePrefix, c.workerPrefix) + _, workers, err := getNodesOxide(ctx, c.logger, client, c.projectID, c.config.ControlPlaneSpec.Prefix, c.config.WorkerSpec.Prefix) if err != nil { return nil, fmt.Errorf("failed to get nodes: %w", err) } @@ -290,23 +299,23 @@ func (c *Cluster) EnsureWorkerNodes(ctx context.Context) ([]oxide.Instance, erro pubkeys = append(pubkeys, string(pubkey)) } var extraNodeDisk string - if c.workerSpec.ExtraDiskSize > 0 { + if c.config.WorkerSpec.ExtraDiskSize > 0 { extraNodeDisk = extraDisk } - cloudConfig, err := GenerateCloudConfigB64("agent", false, c.controlPlaneIP, joinToken, pubkeys, extraNodeDisk, c.workerSpec.TailscaleAuthKey, c.workerSpec.TailscaleTag) + cloudConfig, err := GenerateCloudConfigB64("agent", false, c.config.ControlPlaneIP, joinToken, pubkeys, extraNodeDisk, c.config.WorkerSpec.TailscaleAuthKey, c.config.WorkerSpec.TailscaleTag) if err != nil { return nil, fmt.Errorf("failed to generate cloud config: %w", err) } for i := actualCount; i < int(count); i++ { - workerName := fmt.Sprintf("%s%d", c.workerPrefix, time.Now().Unix()) - instance, err := CreateInstance(ctx, client, c.projectID, workerName, c.workerSpec, cloudConfig) + workerName := fmt.Sprintf("%s%d", c.config.WorkerSpec.Prefix, time.Now().Unix()) + instance, err := CreateInstance(ctx, client, c.projectID, workerName, c.config.WorkerSpec, cloudConfig) if err != nil { return nil, fmt.Errorf("failed to create worker node: %w", err) } nodes = append(nodes, *instance) } - c.logger.Debugf("Created %d worker nodes with prefix %s", count, c.workerPrefix) + c.logger.Debugf("Created %d worker nodes with prefix %s", count, c.config.WorkerSpec.Prefix) return nodes, nil } diff --git a/pkg/cluster/node_test.go b/pkg/cluster/node_test.go index 208bdf0..e98f3c9 100644 --- a/pkg/cluster/node_test.go +++ b/pkg/cluster/node_test.go @@ -1,6 +1,10 @@ package cluster -import "testing" +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) func TestGenerateCloudConfig(t *testing.T) { tests := []struct { @@ -18,8 +22,14 @@ func TestGenerateCloudConfig(t *testing.T) { runcmd: - | PRIVATE_IP=$(hostname -I | awk '{print $1}') + [ -n "${PRIVATE_IP}" ] && K3S_PRIVATE_IP_ARG="--tls-san ${PRIVATE_IP}" + echo "Private IP: ${PRIVATE_IP}" + echo "Private IP k3s arg: ${K3S_PRIVATE_IP_ARG}" PUBLIC_IP=$(curl -s https://ifconfig.me) - curl -sfL https://get.k3s.io | sh -s - server --cluster-init --tls-san 10.0.0.5 --node-external-ip 10.0.0.5 --tls-san ${PRIVATE_IP} --tls-san ${PUBLIC_IP} + [ -n "${PUBLIC_IP}" ] && K3S_PUBLIC_IP_ARG="--tls-san ${PUBLIC_IP}" + echo "Public IP: ${PUBLIC_IP}" + echo "Public IP k3s arg: ${K3S_PUBLIC_IP_ARG}" + curl -sfL https://get.k3s.io | sh -s - server --cluster-init --tls-san 10.0.0.5 --node-external-ip 10.0.0.5 ${K3S_PRIVATE_IP_ARG} ${K3S_PUBLIC_IP_ARG} users: - name: root shell: /bin/bash @@ -33,8 +43,14 @@ allow_public_ssh_keys: true runcmd: - | PRIVATE_IP=$(hostname -I | awk '{print $1}') + [ -n "${PRIVATE_IP}" ] && K3S_PRIVATE_IP_ARG="--tls-san ${PRIVATE_IP}" + echo "Private IP: ${PRIVATE_IP}" + echo "Private IP k3s arg: ${K3S_PRIVATE_IP_ARG}" PUBLIC_IP=$(curl -s https://ifconfig.me) - curl -sfL https://get.k3s.io | sh -s - server --cluster-init --tls-san 10.0.0.5 --node-external-ip 10.0.0.5 --tls-san ${PRIVATE_IP} --tls-san ${PUBLIC_IP} + [ -n "${PUBLIC_IP}" ] && K3S_PUBLIC_IP_ARG="--tls-san ${PUBLIC_IP}" + echo "Public IP: ${PUBLIC_IP}" + echo "Public IP k3s arg: ${K3S_PUBLIC_IP_ARG}" + curl -sfL https://get.k3s.io | sh -s - server --cluster-init --tls-san 10.0.0.5 --node-external-ip 10.0.0.5 ${K3S_PRIVATE_IP_ARG} ${K3S_PUBLIC_IP_ARG} ssh_pwauth: false disable_root: false allow_public_ssh_keys: true @@ -43,8 +59,14 @@ allow_public_ssh_keys: true runcmd: - | PRIVATE_IP=$(hostname -I | awk '{print $1}') + [ -n "${PRIVATE_IP}" ] && K3S_PRIVATE_IP_ARG="--tls-san ${PRIVATE_IP}" + echo "Private IP: ${PRIVATE_IP}" + echo "Private IP k3s arg: ${K3S_PRIVATE_IP_ARG}" PUBLIC_IP=$(curl -s https://ifconfig.me) - curl -sfL https://get.k3s.io | sh -s - server --server https://10.0.0.5:6443 --token joinme --tls-san ${PRIVATE_IP} --tls-san ${PUBLIC_IP} + [ -n "${PUBLIC_IP}" ] && K3S_PUBLIC_IP_ARG="--tls-san ${PUBLIC_IP}" + echo "Public IP: ${PUBLIC_IP}" + echo "Public IP k3s arg: ${K3S_PUBLIC_IP_ARG}" + curl -sfL https://get.k3s.io | sh -s - server --server https://10.0.0.5:6443 --token joinme ${K3S_PRIVATE_IP_ARG} ${K3S_PUBLIC_IP_ARG} users: - name: root shell: /bin/bash @@ -58,8 +80,14 @@ allow_public_ssh_keys: true runcmd: - | PRIVATE_IP=$(hostname -I | awk '{print $1}') + [ -n "${PRIVATE_IP}" ] && K3S_PRIVATE_IP_ARG="--tls-san ${PRIVATE_IP}" + echo "Private IP: ${PRIVATE_IP}" + echo "Private IP k3s arg: ${K3S_PRIVATE_IP_ARG}" PUBLIC_IP=$(curl -s https://ifconfig.me) - curl -sfL https://get.k3s.io | sh -s - server --server https://10.0.0.5:6443 --token joinme --tls-san ${PRIVATE_IP} --tls-san ${PUBLIC_IP} + [ -n "${PUBLIC_IP}" ] && K3S_PUBLIC_IP_ARG="--tls-san ${PUBLIC_IP}" + echo "Public IP: ${PUBLIC_IP}" + echo "Public IP k3s arg: ${K3S_PUBLIC_IP_ARG}" + curl -sfL https://get.k3s.io | sh -s - server --server https://10.0.0.5:6443 --token joinme ${K3S_PRIVATE_IP_ARG} ${K3S_PUBLIC_IP_ARG} ssh_pwauth: false disable_root: false allow_public_ssh_keys: true @@ -68,7 +96,13 @@ allow_public_ssh_keys: true runcmd: - | PRIVATE_IP=$(hostname -I | awk '{print $1}') + [ -n "${PRIVATE_IP}" ] && K3S_PRIVATE_IP_ARG="--tls-san ${PRIVATE_IP}" + echo "Private IP: ${PRIVATE_IP}" + echo "Private IP k3s arg: ${K3S_PRIVATE_IP_ARG}" PUBLIC_IP=$(curl -s https://ifconfig.me) + [ -n "${PUBLIC_IP}" ] && K3S_PUBLIC_IP_ARG="--tls-san ${PUBLIC_IP}" + echo "Public IP: ${PUBLIC_IP}" + echo "Public IP k3s arg: ${K3S_PUBLIC_IP_ARG}" curl -sfL https://get.k3s.io | sh -s - agent --server https://10.0.0.5:6443 --token joinme users: - name: root @@ -83,7 +117,13 @@ allow_public_ssh_keys: true runcmd: - | PRIVATE_IP=$(hostname -I | awk '{print $1}') + [ -n "${PRIVATE_IP}" ] && K3S_PRIVATE_IP_ARG="--tls-san ${PRIVATE_IP}" + echo "Private IP: ${PRIVATE_IP}" + echo "Private IP k3s arg: ${K3S_PRIVATE_IP_ARG}" PUBLIC_IP=$(curl -s https://ifconfig.me) + [ -n "${PUBLIC_IP}" ] && K3S_PUBLIC_IP_ARG="--tls-san ${PUBLIC_IP}" + echo "Public IP: ${PUBLIC_IP}" + echo "Public IP k3s arg: ${K3S_PUBLIC_IP_ARG}" curl -sfL https://get.k3s.io | sh -s - agent --server https://10.0.0.5:6443 --token joinme ssh_pwauth: false disable_root: false @@ -98,8 +138,8 @@ allow_public_ssh_keys: true t.Errorf("expected error %v, got %v", tt.err, err) } resultStr := string(result) - if resultStr != tt.expected { - t.Errorf("expected %s, got %s", tt.expected, resultStr) + if diff := cmp.Diff(tt.expected, resultStr); diff != "" { + t.Errorf("Mismatch (-want +got):\n%s", diff) } }) } diff --git a/pkg/cluster/secret.go b/pkg/cluster/secret.go index ce27b15..b94c325 100644 --- a/pkg/cluster/secret.go +++ b/pkg/cluster/secret.go @@ -2,10 +2,10 @@ package cluster import ( "context" + "encoding/json" "fmt" - "strconv" - "strings" + "github.com/aifoundry-org/oxide-controller/pkg/config" log "github.com/sirupsen/logrus" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" @@ -14,107 +14,69 @@ import ( "k8s.io/client-go/rest" ) -// getSecretValue retrieves a specific value from the secret -func getSecretValue(ctx context.Context, apiConfig *rest.Config, logger *log.Entry, namespace, secret, key string) ([]byte, error) { - logger.Debugf("Getting secret value for key '%s' from secret '%s'", key, secret) - secretData, err := getSecret(ctx, apiConfig, logger, namespace, secret) - if err != nil { - return nil, fmt.Errorf("failed to get secret: %w", err) - } - value, ok := secretData[key] - if !ok { - return nil, NewSecretKeyNotFoundError(key) - } - // no need to base64-decode, since the API returns the raw secret - return value, nil -} - // GetJoinToken retrieves a new k3s worker join token from the Kubernetes cluster func (c *Cluster) GetJoinToken(ctx context.Context) (string, error) { - value, err := getSecretValue(ctx, c.apiConfig.Config, c.logger, c.namespace, c.secretName, secretKeyJoinToken) + conf, err := getSecretConfig(ctx, c.apiConfig.Config, c.logger, c.config.ControlPlaneNamespace, c.config.SecretName) if err != nil { return "", err } - // convert to string - valStr := string(value) - // remove trailing newlines - return strings.TrimSuffix(valStr, "\n"), nil + return conf.K3sJoinToken, nil } // GetUserSSHPublicKey retrieves the SSH public key from the Kubernetes cluster func (c *Cluster) GetUserSSHPublicKey(ctx context.Context) ([]byte, error) { - pubkey, err := getSecretValue(ctx, c.apiConfig.Config, c.logger, c.namespace, c.secretName, secretKeyUserSSH) + conf, err := getSecretConfig(ctx, c.apiConfig.Config, c.logger, c.config.ControlPlaneNamespace, c.config.SecretName) if err != nil { return nil, err } - return pubkey, nil + return []byte(conf.UserSSHPublicKey), nil } // GetOxideToken retrieves the oxide token from the Kubernetes cluster func (c *Cluster) GetOxideToken(ctx context.Context) ([]byte, error) { - pubkey, err := getSecretValue(ctx, c.apiConfig.Config, c.logger, c.namespace, c.secretName, secretKeyOxideToken) - if err != nil { - return nil, err - } - return pubkey, nil + return GetOxideToken(ctx, c.apiConfig.Config, c.logger, c.config.ControlPlaneNamespace, c.config.SecretName) } // GetOxideURL retrieves the oxide URL from the Kubernetes cluster func (c *Cluster) GetOxideURL(ctx context.Context) ([]byte, error) { - pubkey, err := getSecretValue(ctx, c.apiConfig.Config, c.logger, c.namespace, c.secretName, secretKeyOxideURL) - if err != nil { - return nil, err - } - return pubkey, nil + return GetOxideURL(ctx, c.apiConfig.Config, c.logger, c.config.ControlPlaneNamespace, c.config.SecretName) } // GetWorkerCount retrieves the targeted worker count from the Kubernetes cluster func (c *Cluster) GetWorkerCount(ctx context.Context) (int, error) { - workerCount, err := getSecretValue(ctx, c.apiConfig.Config, c.logger, c.namespace, c.secretName, secretKeyWorkerCount) + conf, err := getSecretConfig(ctx, c.apiConfig.Config, c.logger, c.config.ControlPlaneNamespace, c.config.SecretName) if err != nil { return 0, err } - // convert to string - valStr := string(workerCount) - // remove trailing newlines - valStr = strings.TrimSuffix(valStr, "\n") - // convert to int - count, err := strconv.Atoi(valStr) - if err != nil { - return 0, fmt.Errorf("failed to convert worker count to int: %w", err) - } - return count, nil + return int(conf.WorkerCount), nil } // SetWorkerCount sets the targeted worker count in the Kubernetes cluster func (c *Cluster) SetWorkerCount(ctx context.Context, count int) error { - secretMap, err := getSecret(ctx, c.apiConfig.Config, c.logger, c.namespace, c.secretName) + conf, err := getSecretConfig(ctx, c.apiConfig.Config, c.logger, c.config.ControlPlaneNamespace, c.config.SecretName) if err != nil { return fmt.Errorf("failed to get secret: %w", err) } - secretMap[secretKeyWorkerCount] = []byte(fmt.Sprintf("%d", count)) - if err := saveSecret(ctx, c.clientset, c.logger, c.namespace, c.secretName, secretMap); err != nil { - return fmt.Errorf("failed to save secret: %w", err) - } - return nil + conf.WorkerCount = uint(count) + return setSecretConfig(ctx, c.apiConfig.Config, c.logger, c.config.ControlPlaneNamespace, c.config.SecretName, conf) } // GetOxideToken retrieves the oxide token from the Kubernetes cluster func GetOxideToken(ctx context.Context, restConfig *rest.Config, logger *log.Entry, namespace, secretName string) ([]byte, error) { - pubkey, err := getSecretValue(ctx, restConfig, logger, namespace, secretName, secretKeyOxideToken) + conf, err := getSecretConfig(ctx, restConfig, logger, namespace, secretName) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get secret: %w", err) } - return pubkey, nil + return []byte(conf.OxideToken), nil } // GetOxideURL retrieves the oxide URL from the Kubernetes cluster func GetOxideURL(ctx context.Context, restConfig *rest.Config, logger *log.Entry, namespace, secretName string) ([]byte, error) { - pubkey, err := getSecretValue(ctx, restConfig, logger, namespace, secretName, secretKeyOxideURL) + conf, err := getSecretConfig(ctx, restConfig, logger, namespace, secretName) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get secret: %w", err) } - return pubkey, nil + return []byte(conf.OxideURL), nil } // getSecret gets the secret with all of our important information @@ -172,3 +134,57 @@ func saveSecret(ctx context.Context, clientset *kubernetes.Clientset, logger *lo } return nil } + +// getSecretValue retrieves a specific value from the secret +func getSecretValue(ctx context.Context, apiConfig *rest.Config, logger *log.Entry, namespace, secret, key string) ([]byte, error) { + logger.Debugf("Getting secret value for key '%s' from secret '%s'", key, secret) + secretData, err := getSecret(ctx, apiConfig, logger, namespace, secret) + if err != nil { + return nil, fmt.Errorf("failed to get secret: %w", err) + } + value, ok := secretData[key] + if !ok { + return nil, NewSecretKeyNotFoundError(key) + } + // no need to base64-decode, since the API returns the raw secret + return value, nil +} + +// setSecretValue sets a specific value in the secret +func setSecretValue(ctx context.Context, apiConfig *rest.Config, logger *log.Entry, namespace, secret, key string, value []byte) error { + logger.Debugf("Setting secret value for key '%s' in secret '%s'", key, secret) + secretData, err := getSecret(ctx, apiConfig, logger, namespace, secret) + if err != nil { + return fmt.Errorf("failed to get secret: %w", err) + } + secretData[key] = value + clientset, err := getClientset(apiConfig) + if err != nil { + return fmt.Errorf("failed to get Kubernetes clientset: %w", err) + } + return saveSecret(ctx, clientset, logger, namespace, secret, secretData) +} + +// func getSecretValue(ctx context.Context, apiConfig *rest.Config, logger *log.Entry, namespace, secret, key string) ([]byte, error) { +func getSecretConfig(ctx context.Context, apiConfig *rest.Config, logger *log.Entry, namespace, secret string) (*config.ControllerConfig, error) { + logger.Debugf("Getting controller config from secret '%s'", secret) + data, err := getSecretValue(ctx, apiConfig, logger, namespace, secret, secretKeyConfig) + if err != nil { + return nil, fmt.Errorf("failed to get controller config: %w", err) + } + var config config.ControllerConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to unmarshal controller config: %w", err) + } + return &config, nil +} + +// func setSecretValue(ctx context.Context, apiConfig *rest.Config, logger *log.Entry, namespace, secret, key string) ([]byte, error) { +func setSecretConfig(ctx context.Context, apiConfig *rest.Config, logger *log.Entry, namespace, secret string, conf *config.ControllerConfig) error { + logger.Debugf("Getting controller config from secret '%s'", secret) + b, err := conf.ToJSON() + if err != nil { + return fmt.Errorf("failed to marshal controller config: %w", err) + } + return setSecretValue(ctx, apiConfig, logger, namespace, secret, secretKeyConfig, b) +} diff --git a/pkg/cluster/types.go b/pkg/cluster/types.go deleted file mode 100644 index 01a9c38..0000000 --- a/pkg/cluster/types.go +++ /dev/null @@ -1,21 +0,0 @@ -package cluster - -// Node represents a Kubernetes node -type NodeSpec struct { - Image Image `json:"image"` - MemoryGB int `json:"memoryGB"` - CPUCount int `json:"cpuCount"` - RootDiskSize int `json:"diskSize"` - ExtraDiskSize int `json:"extraDiskSize"` - ExternalIP bool `json:"externalIP"` - TailscaleAuthKey string `json:"tailscaleAuthKey"` - TailscaleTag string `json:"tailscaleTag"` -} - -type Image struct { - Name string `json:"name"` - Source string `json:"source"` - Blocksize int `json:"blocksize"` - ID string `json:"id"` - Size int `json:"size"` -} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..26ba6f2 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,52 @@ +package config + +import "encoding/json" + +type ControllerConfig struct { + WorkerSpec NodeSpec `json:"worker-spec,omitempty"` + WorkerCount uint `json:"worker-count,omitempty"` + ControlPlaneCount uint `json:"control-plane-count,omitempty"` + ControlPlaneSpec NodeSpec `json:"control-plane-spec,omitempty"` + ControlPlaneIP string `json:"control-plane-ip,omitempty"` + UserSSHPublicKey string `json:"user-ssh-public-key,omitempty"` + K3sJoinToken string `json:"k3s-join-token,omitempty"` + SystemSSHPublicKey string `json:"system-ssh-public-key,omitempty"` + SystemSSHPrivateKey string `json:"system-ssh-private-key,omitempty"` + OxideToken string `json:"oxide-token,omitempty"` + OxideURL string `json:"oxide-url,omitempty"` + ClusterProject string `json:"cluster-project,omitempty"` + ControlPlaneNamespace string `json:"control-plane-namespace,omitempty"` + SecretName string `json:"secret-name,omitempty"` + Address string `json:"address,omitempty"` + ControlLoopMins int `json:"control-loop-mins,omitempty"` + ImageParallelism int `json:"image-parallelism,omitempty"` + TailscaleAuthKey string `json:"tailscale-auth-key,omitempty"` + TailscaleAPIKey string `json:"tailscale-api-key,omitempty"` + TailscaleTag string `json:"tailscale-tag,omitempty"` + TailscaleTailnet string `json:"tailscale-tailnet,omitempty"` +} + +// Node represents a Kubernetes node +type NodeSpec struct { + Prefix string `json:"prefix"` + Image Image `json:"image"` + MemoryGB int `json:"memoryGB"` + CPUCount int `json:"cpuCount"` + RootDiskSize int `json:"diskSize"` + ExtraDiskSize int `json:"extraDiskSize"` + ExternalIP bool `json:"externalIP"` + TailscaleAuthKey string `json:"tailscaleAuthKey"` + TailscaleTag string `json:"tailscaleTag"` +} + +type Image struct { + Name string `json:"name"` + Source string `json:"source"` + Blocksize int `json:"blocksize"` + ID string `json:"id"` + Size int `json:"size"` +} + +func (c *ControllerConfig) ToJSON() ([]byte, error) { + return json.Marshal(c) +}