Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,7 @@ write_files:

runcmd:
- export UNBOUNDED_AGENT_CONFIG_FILE=/etc/unbounded/agent/config.json
{{- range .InstallEnv }}
- export {{ . }}
{{- end }}
- bash /usr/local/bin/unbounded-agent-install.sh
11 changes: 7 additions & 4 deletions cmd/kubectl-unbounded/app/assets/node-bootstrap/script.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,16 @@ cat > "${UNBOUNDED_AGENT_CONFIG_FILE}" <<'AGENT_CONFIG_EOF'
AGENT_CONFIG_EOF

# -----------------------------------------------------------------
# Embedded install script
# Agent download overrides (injected by manual-bootstrap flags)
# -----------------------------------------------------------------

{{- if .AgentURLLiteral }}
export AGENT_URL={{ .AgentURLLiteral }}
{{- range .InstallEnv }}
export {{ . }}
{{- end }}

# -----------------------------------------------------------------
# Embedded install script
# -----------------------------------------------------------------

bash <<'INSTALL_SCRIPT_EOF'
{{ .InstallScript }}
INSTALL_SCRIPT_EOF
79 changes: 60 additions & 19 deletions cmd/kubectl-unbounded/app/machine_manual_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,24 @@ type manualBootstrapHandler struct {
// via the discovery client.
kubernetesVersion string

// variant controls the output format. Defaults to "script".
variant string
// agentVersion pins the unbounded-agent release tag to download on the
// target host. When empty (the default) the install script tracks the
// latest published release.
agentVersion string

// agentURL optionally overrides the embedded install script download URL.
// agentURL is a fully qualified override for the unbounded-agent download
// URL. When set it takes precedence over agentVersion and agentBaseURL.
agentURL string

// agentBaseURL overrides the base URL used to construct the download URL
// for the unbounded-agent. Useful for self-hosted release mirrors. Must
// follow the same layout as GitHub releases
// (<base>/latest/download/<asset> and <base>/download/<tag>/<asset>).
agentBaseURL string

// variant controls the output format. Defaults to "script".
variant string

// kubeconfigPath is the path to the kubeconfig used to contact the cluster.
kubeconfigPath string

Expand Down Expand Up @@ -173,10 +185,6 @@ func (h *manualBootstrapHandler) validate() error {
return err
}

if h.agentURL != "" && bootstrapVariant(h.variant) != variantScript {
return errors.New("--agent-url is only supported with --variant script")
}

h.kubeconfigPath = getKubeconfigPath(h.kubeconfigPath)

if !isReadableFile(h.kubeconfigPath) {
Expand Down Expand Up @@ -293,13 +301,38 @@ type manualBootstrapTemplateData struct {
// heredoc that is piped to bash.
InstallScript string

// AgentURLLiteral optionally overrides the agent download location used by
// the embedded install script as a shell-safe literal.
AgentURLLiteral string
// InstallEnv is an optional list of "KEY=VALUE" strings that are exported
// in the generated script's shell (or cloud-init runcmd) immediately
// before the embedded install script runs. Used to forward agent
// download overrides like AGENT_VERSION, AGENT_URL, and AGENT_BASE_URL.
InstallEnv []string
}

func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'"
// installEnv returns the KEY=VALUE pairs that should be exported before the
// embedded install script runs. Only non-empty overrides are included.
func (h *manualBootstrapHandler) installEnv() []string {
var env []string

if h.agentVersion != "" {
env = append(env, fmt.Sprintf("AGENT_VERSION=%s", shellSingleQuote(h.agentVersion)))
}

if h.agentBaseURL != "" {
env = append(env, fmt.Sprintf("AGENT_BASE_URL=%s", shellSingleQuote(h.agentBaseURL)))
}

if h.agentURL != "" {
env = append(env, fmt.Sprintf("AGENT_URL=%s", shellSingleQuote(h.agentURL)))
}

return env
}

// shellSingleQuote wraps v in POSIX-safe single quotes, escaping any embedded
// single quotes. The result can be used verbatim on the right-hand side of
// an `export KEY=...` statement in bash.
func shellSingleQuote(v string) string {
return "'" + strings.ReplaceAll(v, "'", `'\''`) + "'"
}

// renderScript produces a self-contained bash script that writes the agent
Expand All @@ -315,10 +348,7 @@ func (h *manualBootstrapHandler) renderScript(cfg *provision.AgentConfig) (strin
MachineName: cfg.MachineName,
AgentConfigJSON: string(configJSON),
InstallScript: provision.UnboundedAgentInstallScript(),
}

if h.agentURL != "" {
data.AgentURLLiteral = shellQuote(h.agentURL)
InstallEnv: h.installEnv(),
}

t, err := template.New("node-bootstrap").Parse(manualBootstrapTemplate)
Expand Down Expand Up @@ -346,6 +376,7 @@ func (h *manualBootstrapHandler) renderCloudInit(cfg *provision.AgentConfig) (st
MachineName: cfg.MachineName,
AgentConfigJSON: string(configJSON),
InstallScript: provision.UnboundedAgentInstallScript(),
InstallEnv: h.installEnv(),
}

funcMap := template.FuncMap{
Expand Down Expand Up @@ -381,7 +412,7 @@ func newMachineManualBootstrapCommand(handler *manualBootstrapHandler) *cobra.Co
Use: "manual-bootstrap NAME",
Short: "Generate a bootstrap script or cloud-init config for provisioning a machine",
Long: `Generate a self-contained bootstrap payload that provisions a bare-metal or VM
host as an unbounded-kube worker node. The payload embeds the agent JSON
host as an unbounded worker node. The payload embeds the agent JSON
configuration inline and the install script for the target architecture.

Use --variant to choose the output format:
Expand All @@ -398,7 +429,15 @@ Examples:
kubectl unbounded machine manual-bootstrap my-node --site my-site | ssh user@host sudo bash

# Generate cloud-init user-data for a cloud provider API:
kubectl unbounded machine manual-bootstrap my-node --site my-site --variant cloud-init > user-data.yaml`,
kubectl unbounded machine manual-bootstrap my-node --site my-site --variant cloud-init > user-data.yaml

# Pin the agent to a specific release instead of tracking "latest":
kubectl unbounded machine manual-bootstrap my-node --site my-site --agent-version v0.0.10

# Self-host / mirror the release assets (expects the same layout as
# GitHub releases under the base URL):
kubectl unbounded machine manual-bootstrap my-node --site my-site \
--agent-base-url https://releases.example.com/unbounded`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
handler.machineName = args[0]
Expand All @@ -413,7 +452,9 @@ Examples:
cmd.Flags().StringVar(&handler.ociImage, "oci-image", "", "OCI image reference for the agent rootfs")
cmd.Flags().StringVar(&handler.kubernetesVersion, "kubernetes-version", "", "Override the Kubernetes version (default: auto-detected from API server)")
cmd.Flags().StringVar(&handler.variant, "variant", "script", "Output format: script or cloud-init")
cmd.Flags().StringVar(&handler.agentURL, "agent-url", "", "Override URL used by the embedded agent install script")
cmd.Flags().StringVar(&handler.agentVersion, "agent-version", "", "Pin the unbounded-agent release tag to download on the host (default: latest GitHub release)")
cmd.Flags().StringVar(&handler.agentURL, "agent-url", "", "Fully qualified download URL for the unbounded-agent tarball (overrides --agent-version and --agent-base-url)")
cmd.Flags().StringVar(&handler.agentBaseURL, "agent-base-url", "", "Base URL for unbounded-agent release downloads (default: https://github.com/Azure/unbounded/releases). Use this to self-host or mirror release assets")

if err := cmd.MarkFlagRequired("site"); err != nil {
panic(err)
Expand Down
180 changes: 169 additions & 11 deletions cmd/kubectl-unbounded/app/machine_manual_bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"bytes"
"context"
"encoding/json"
"os"
"os/exec"
"strings"
"testing"

Expand Down Expand Up @@ -104,17 +106,6 @@ func TestManualBootstrapHandler_Validate(t *testing.T) {
variant: "cloud-init",
},
},
{
name: "invalid: agent-url with cloud-init variant",
handler: manualBootstrapHandler{
siteName: "dc1",
machineName: "my-node",
kubeconfigPath: kubeconfigPath,
variant: "cloud-init",
agentURL: "file:///tmp/unbounded-agent-linux-amd64.tar.gz",
},
expectErr: "--agent-url is only supported with --variant script",
},
{
name: "valid: variant defaults to script when empty",
handler: manualBootstrapHandler{
Expand Down Expand Up @@ -289,6 +280,13 @@ func TestManualBootstrapHandler_RenderScript(t *testing.T) {
script, err := h.renderScript(cfg)
require.NoError(t, err)

// No download overrides: no `export AGENT_*=` lines should appear in
// the outer script (the embedded install script references the vars
// in its own help text, but must not be pre-set by the wrapper).
require.NotContains(t, script, "export AGENT_VERSION=")
require.NotContains(t, script, "export AGENT_URL=")
require.NotContains(t, script, "export AGENT_BASE_URL=")

// Should start with a shebang.
require.Contains(t, script, "#!/bin/bash")
require.Contains(t, script, "set -eo pipefail")
Expand Down Expand Up @@ -601,3 +599,163 @@ func TestManualBootstrapHandler_Execute(t *testing.T) {
require.Contains(t, output, "/etc/unbounded/agent/config.json")
})
}

// ---------------------------------------------------------------------------
// Agent download override tests
// ---------------------------------------------------------------------------

func TestManualBootstrapHandler_InstallEnv(t *testing.T) {
t.Parallel()

tests := []struct {
name string
handler manualBootstrapHandler
want []string
}{
{
name: "no overrides",
handler: manualBootstrapHandler{},
want: nil,
},
{
name: "pinned version",
handler: manualBootstrapHandler{agentVersion: "v0.0.10"},
want: []string{"AGENT_VERSION='v0.0.10'"},
},
{
name: "base URL override",
handler: manualBootstrapHandler{agentBaseURL: "https://mirror.example.com/releases"},
want: []string{"AGENT_BASE_URL='https://mirror.example.com/releases'"},
},
{
name: "full URL override",
handler: manualBootstrapHandler{agentURL: "https://mirror.example.com/agent.tar.gz"},
want: []string{"AGENT_URL='https://mirror.example.com/agent.tar.gz'"},
},
{
name: "all three set",
handler: manualBootstrapHandler{
agentVersion: "v0.0.10",
agentBaseURL: "https://mirror.example.com/releases",
agentURL: "https://mirror.example.com/agent.tar.gz",
},
want: []string{
"AGENT_VERSION='v0.0.10'",
"AGENT_BASE_URL='https://mirror.example.com/releases'",
"AGENT_URL='https://mirror.example.com/agent.tar.gz'",
},
},
{
name: "value containing a single quote is escaped",
handler: manualBootstrapHandler{agentVersion: "v'1"},
want: []string{`AGENT_VERSION='v'\''1'`},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got := tt.handler.installEnv()
require.Equal(t, tt.want, got)
})
}
}

func TestManualBootstrapHandler_RenderScript_DownloadOverrides(t *testing.T) {
t.Parallel()

cfg := &provision.AgentConfig{
MachineName: "test-node",
Cluster: provision.AgentClusterConfig{
CaCertBase64: "dGVzdA==",
ClusterDNS: "10.0.0.10",
Version: "v1.30.0",
},
Kubelet: provision.AgentKubeletConfig{
ApiServer: "https://api-server:6443",
BootstrapToken: "abc123.0123456789abcdef",
Labels: map[string]string{"env": "prod"},
},
}

h := &manualBootstrapHandler{
logger: discardLogger(),
agentVersion: "v0.0.10",
agentBaseURL: "https://mirror.example.com/releases",
}

script, err := h.renderScript(cfg)
require.NoError(t, err)

// Overrides must be exported in the outer shell before the embedded
// install script heredoc.
require.Contains(t, script, "export AGENT_VERSION='v0.0.10'")
require.Contains(t, script, "export AGENT_BASE_URL='https://mirror.example.com/releases'")

// The exports must appear before the embedded install script heredoc.
exportIdx := strings.Index(script, "export AGENT_VERSION=")
heredocIdx := strings.Index(script, "INSTALL_SCRIPT_EOF")
require.Greater(t, exportIdx, 0)
require.Greater(t, heredocIdx, exportIdx)

// Script should still be valid bash syntax.
requireValidBashSyntax(t, script)
}

func TestManualBootstrapHandler_RenderCloudInit_DownloadOverrides(t *testing.T) {
t.Parallel()

cfg := &provision.AgentConfig{
MachineName: "test-node",
Cluster: provision.AgentClusterConfig{
CaCertBase64: "dGVzdA==",
ClusterDNS: "10.0.0.10",
Version: "v1.30.0",
},
Kubelet: provision.AgentKubeletConfig{
ApiServer: "https://api-server:6443",
BootstrapToken: "abc123.0123456789abcdef",
},
}

h := &manualBootstrapHandler{
logger: discardLogger(),
agentURL: "https://mirror.example.com/agent.tar.gz",
}

output, err := h.renderCloudInit(cfg)
require.NoError(t, err)

// The override must be exported in runcmd before invoking the install
// script.
require.Contains(t, output, "export AGENT_URL='https://mirror.example.com/agent.tar.gz'")

exportIdx := strings.Index(output, "export AGENT_URL=")
runIdx := strings.Index(output, "bash /usr/local/bin/unbounded-agent-install.sh")
require.Greater(t, exportIdx, 0)
require.Greater(t, runIdx, exportIdx)
}

// requireValidBashSyntax shells out to `bash -n` to syntax-check a script.
// It skips the test if bash is not available in the test environment.
func requireValidBashSyntax(t *testing.T, script string) {
t.Helper()

bashPath, err := exec.LookPath("bash")
if err != nil {
t.Skipf("bash not found in PATH: %v", err)
}

f, err := os.CreateTemp(t.TempDir(), "bootstrap-*.sh")
require.NoError(t, err)

_, err = f.WriteString(script)
require.NoError(t, err)
require.NoError(t, f.Close())

cmd := exec.Command(bashPath, "-n", f.Name())

out, err := cmd.CombinedOutput()
require.NoError(t, err, "bash -n failed: %s", string(out))
}
Loading
Loading