package edgecmd import ( "crypto/sha256" "errors" "fmt" "io" "os" "path/filepath" "runtime" "strconv" "strings" "github.com/spf13/cobra" "iop/packages/go/config" "iop/packages/go/version" ) func bootstrapCmd() *cobra.Command { c := &cobra.Command{ Use: "bootstrap", Short: "Prepare node bootstrap artifacts", Long: `Prepare node bootstrap artifacts served by iop-edge serve. The default output directory is the configured bootstrap.artifact_dir resolved beside the iop-edge binary. The directory is static and can be copied as-is.`, } c.AddCommand(bootstrapPackCmd()) return c } func bootstrapPackCmd() *cobra.Command { var nodeBinary string var outputDir string var target string var artifactBaseURL string var edgeAddr string c := &cobra.Command{ Use: "pack", Short: "Pack iop-node into the configured bootstrap artifact directory", RunE: func(cmd *cobra.Command, _ []string) error { cfg, err := loadEdgeConfigForBootstrapPack(cmd) if err != nil { return err } adv := resolveAdvertiseHost(cfg.Server.AdvertiseHost) if nodeBinary == "" { nodeBinary = filepath.Join(binaryDir(), "iop-node") } if outputDir == "" { outputDir = ResolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir) } if target == "" { target = runtime.GOOS + "-" + runtime.GOARCH } if artifactBaseURL == "" { if cfg.Bootstrap.ArtifactBaseURL != "" { artifactBaseURL = strings.TrimSuffix(cfg.Bootstrap.ArtifactBaseURL, "/") } else { artifactBaseURL = defaultArtifactBaseURL(adv.Host, cfg.Bootstrap.Listen) } } if edgeAddr == "" { edgeAddr = addressFor(adv.Host, cfg.Server.Listen) } if err := packNodeBootstrap(nodeBootstrapPackOptions{ NodeBinary: nodeBinary, OutputDir: outputDir, Target: target, Version: version.Version, ArtifactBaseURL: artifactBaseURL, EdgeAddr: edgeAddr, }); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "wrote bootstrap artifacts to %s\n", outputDir) fmt.Fprintf(cmd.OutOrStdout(), "bootstrap: %s\n", universalBootstrapScriptURL(artifactBaseURL)) return nil }, } c.Flags().StringVar(&nodeBinary, "node-binary", "", "iop-node binary to pack (defaults to iop-node beside iop-edge)") c.Flags().StringVarP(&outputDir, "output", "o", "", "artifact output directory (defaults to bootstrap.artifact_dir)") c.Flags().StringVar(&target, "target", "", "target platform label (defaults to current GOOS-GOARCH)") c.Flags().StringVar(&artifactBaseURL, "artifact-base-url", "", "artifact base URL baked into the bootstrap script") c.Flags().StringVar(&edgeAddr, "edge-addr", "", "edge transport address baked into node.yaml") return c } func loadEdgeConfigForBootstrapPack(cmd *cobra.Command) (*config.EdgeConfig, error) { cfg, _, err := LoadEdgeConfig(cmd) if err == nil { return cfg, nil } if !strings.Contains(err.Error(), "no edge.yaml found") { return nil, err } return &config.EdgeConfig{ Server: config.EdgeServerConf{ Listen: "0.0.0.0:9090", }, Bootstrap: config.EdgeBootstrapConf{ Listen: "0.0.0.0:18080", ArtifactDir: "artifacts", }, }, nil } type nodeBootstrapPackOptions struct { NodeBinary string OutputDir string Target string Version string ArtifactBaseURL string EdgeAddr string } func packNodeBootstrap(opts nodeBootstrapPackOptions) error { if opts.NodeBinary == "" { return errors.New("node binary path is required") } if opts.OutputDir == "" { return errors.New("output directory is required") } if opts.Target == "" { return errors.New("target is required") } if strings.ContainsAny(opts.Target, `/\`) { return fmt.Errorf("target %q must not contain path separators", opts.Target) } targetDir := filepath.Join(opts.OutputDir, opts.Target) if err := os.MkdirAll(targetDir, 0o755); err != nil { return fmt.Errorf("mkdir target dir: %w", err) } dstNode := filepath.Join(targetDir, "iop-node") same, err := sameFile(opts.NodeBinary, dstNode) if err != nil { return fmt.Errorf("stat iop-node: %w", err) } if same { if err := os.Chmod(dstNode, 0o755); err != nil { return fmt.Errorf("chmod iop-node: %w", err) } } else { if err := copyFile(opts.NodeBinary, dstNode, 0o755); err != nil { return fmt.Errorf("copy iop-node: %w", err) } } sum, err := fileSHA256(dstNode) if err != nil { return err } sumLine := fmt.Sprintf("%x iop-node\n", sum) if err := os.WriteFile(filepath.Join(targetDir, "iop-node.sha256"), []byte(sumLine), 0o644); err != nil { return fmt.Errorf("write iop-node.sha256: %w", err) } if err := os.WriteFile(filepath.Join(targetDir, "SHA256SUMS"), []byte(sumLine), 0o644); err != nil { return fmt.Errorf("write SHA256SUMS: %w", err) } bootstrapDir := filepath.Join(opts.OutputDir, "bootstrap") if err := os.MkdirAll(bootstrapDir, 0o755); err != nil { return fmt.Errorf("mkdir bootstrap dir: %w", err) } script := nodeBootstrapScript(opts) scriptPath := filepath.Join(bootstrapDir, "node-"+opts.Target+".sh") if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { return fmt.Errorf("write bootstrap script: %w", err) } if strings.HasPrefix(opts.Target, "windows-") { psScriptPath := filepath.Join(bootstrapDir, "node-"+opts.Target+".ps1") if err := os.WriteFile(psScriptPath, []byte(nodeBootstrapPowerShellScript(opts)), 0o644); err != nil { return fmt.Errorf("write powershell bootstrap script: %w", err) } } if err := writeUniversalBootstrapScript(bootstrapDir, opts); err != nil { return err } return nil } func writeUniversalBootstrapScript(bootstrapDir string, opts nodeBootstrapPackOptions) error { universalOpts := opts universalOpts.Target = "" scriptPath := filepath.Join(bootstrapDir, "node.sh") if err := os.MkdirAll(bootstrapDir, 0o755); err != nil { return fmt.Errorf("mkdir bootstrap dir: %w", err) } if err := os.WriteFile(scriptPath, []byte(nodeBootstrapScript(universalOpts)), 0o755); err != nil { return fmt.Errorf("write universal bootstrap script: %w", err) } return nil } func sameFile(a, b string) (bool, error) { ai, err := os.Stat(a) if err != nil { return false, err } bi, err := os.Stat(b) if errors.Is(err, os.ErrNotExist) { return false, nil } if err != nil { return false, err } return os.SameFile(ai, bi), nil } func copyFile(src, dst string, mode os.FileMode) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { _ = out.Close() return err } if err := out.Close(); err != nil { return err } return os.Chmod(dst, mode) } func fileSHA256(path string) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("open checksum file: %w", err) } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return nil, fmt.Errorf("hash file: %w", err) } return h.Sum(nil), nil } func shellQuote(s string) string { return strconv.Quote(s) } func powershellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" } func bootstrapCommand(artifactURL, target, token string) string { if strings.HasPrefix(target, "windows-") { return fmt.Sprintf("Invoke-RestMethod %s | Invoke-Expression; Start-IopNode %s", bootstrapPowerShellScriptURL(artifactURL, target), powershellQuote(token)) } return fmt.Sprintf("curl -fsSL %s | bash -s %s", universalBootstrapScriptURL(artifactURL), token) } func nodeBootstrapScript(opts nodeBootstrapPackOptions) string { return `#!/usr/bin/env bash set -euo pipefail VERSION=` + shellQuote(opts.Version) + ` DEFAULT_TARGET=` + shellQuote(opts.Target) + ` DEFAULT_ARTIFACT_BASE_URL=` + shellQuote(strings.TrimSuffix(opts.ArtifactBaseURL, "/")) + ` DEFAULT_EDGE_ADDR=` + shellQuote(opts.EdgeAddr) + ` POSITIONAL_NODE_TOKEN="${1:-}" TARGET="$DEFAULT_TARGET" ARTIFACT_BASE_URL="$DEFAULT_ARTIFACT_BASE_URL" EDGE_ADDR="$DEFAULT_EDGE_ADDR" NODE_TOKEN="$POSITIONAL_NODE_TOKEN" INSTALL_DIR="${IOP_HOME:-$HOME/iop-field}" CONFIG_FILE="${IOP_NODE_CONFIG:-$INSTALL_DIR/node.yaml}" TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/iop-node-bootstrap.XXXXXX")" detect_target() { local os arch case "$(uname -s)" in Linux*) os="linux" ;; Darwin*) os="darwin" ;; MINGW*|MSYS*|CYGWIN*) os="windows" ;; *) echo "[iop-bootstrap] unsupported OS: $(uname -s)" >&2 return 2 ;; esac case "$(uname -m)" in x86_64|amd64) arch="amd64" ;; arm64|aarch64) arch="arm64" ;; *) echo "[iop-bootstrap] unsupported architecture: $(uname -m)" >&2 return 2 ;; esac printf '%s-%s\n' "$os" "$arch" } cleanup() { rm -rf "$TMP_DIR" } trap cleanup EXIT ARTIFACT_BASE_URL="${ARTIFACT_BASE_URL%/}" if [[ -z "$NODE_TOKEN" ]]; then echo "[iop-bootstrap] missing node token" >&2 echo "[iop-bootstrap] rerun the complete bootstrap command printed by iop-edge node register" >&2 exit 2 fi if [[ -z "$TARGET" ]]; then TARGET="$(detect_target)" fi BIN_FILE="$INSTALL_DIR/iop-node" if [[ "$TARGET" == windows-* ]]; then BIN_FILE="$INSTALL_DIR/iop-node.exe" fi echo "[iop-bootstrap] version=$VERSION target=$TARGET" echo "[iop-bootstrap] install_dir=$INSTALL_DIR" mkdir -p "$INSTALL_DIR" curl -fsSL "$ARTIFACT_BASE_URL/$TARGET/iop-node" -o "$TMP_DIR/iop-node" curl -fsSL "$ARTIFACT_BASE_URL/$TARGET/SHA256SUMS" -o "$TMP_DIR/SHA256SUMS" if command -v sha256sum >/dev/null 2>&1; then (cd "$TMP_DIR" && grep -E '[ *]iop-node$' SHA256SUMS | sha256sum -c -) elif command -v shasum >/dev/null 2>&1; then (cd "$TMP_DIR" && grep -E '[ *]iop-node$' SHA256SUMS | shasum -a 256 -c -) else echo "[iop-bootstrap] warning: no sha256 checker found; skipping checksum verification" >&2 fi cp "$TMP_DIR/iop-node" "$BIN_FILE" chmod +x "$BIN_FILE" if command -v xattr >/dev/null 2>&1; then xattr -d com.apple.quarantine "$BIN_FILE" 2>/dev/null || true fi cat > "$CONFIG_FILE" <