iop/apps/edge/internal/edgecmd/bootstrap.go
toki ff161b9b12 feat: runtime reconnect config refresh milestone completion
- Add runtime-reconnect-config-refresh milestone and SDD
- Archive m-runtime-reconnect-config-refresh task with full logs
- Update edge/node domain rules, bootstrap, transport sessions
- Update config package and node.yaml configuration
- Update documentation and ROADMAP/PHASE files
2026-06-21 16:14:16 +09:00

420 lines
12 KiB
Go

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" <<YAML
transport:
edge_addr: "$EDGE_ADDR"
token: "$NODE_TOKEN"
logging:
level: "${IOP_LOG_LEVEL:-info}"
pretty: true
metrics:
port: ${IOP_NODE_METRICS_PORT:-9091}
YAML
echo "[iop-bootstrap] config=$CONFIG_FILE"
echo "[iop-bootstrap] starting node against $EDGE_ADDR"
exec "$BIN_FILE" serve --config "$CONFIG_FILE"
`
}
func nodeBootstrapPowerShellScript(opts nodeBootstrapPackOptions) string {
return `$ErrorActionPreference = 'Stop'
function Start-IopNode {
param(
[Parameter(Mandatory = $true, Position = 0)]
[string] $Token
)
$Version = ` + powershellQuote(opts.Version) + `
$Target = ` + powershellQuote(opts.Target) + `
$ArtifactBaseURL = ` + powershellQuote(strings.TrimSuffix(opts.ArtifactBaseURL, "/")) + `
$EdgeAddr = ` + powershellQuote(opts.EdgeAddr) + `
$InstallDir = Join-Path $HOME 'iop-field'
$ConfigFile = Join-Path $InstallDir 'node.yaml'
$LogFile = Join-Path $InstallDir 'iop-node.log'
$LogFileYAML = $LogFile.Replace('\', '/')
$StdoutLog = Join-Path $InstallDir 'iop-node.stdout.log'
$StderrLog = Join-Path $InstallDir 'iop-node.stderr.log'
$TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ('iop-node-bootstrap-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force -Path $InstallDir, $TmpDir | Out-Null
try {
$TmpNode = Join-Path $TmpDir 'iop-node'
Invoke-WebRequest -UseBasicParsing -Uri "$ArtifactBaseURL/$Target/iop-node" -OutFile $TmpNode
$Sums = Invoke-RestMethod -Uri "$ArtifactBaseURL/$Target/SHA256SUMS"
$Expected = (($Sums -split '\r?\n' | Where-Object { $_ -match '\s+iop-node$' } | Select-Object -First 1) -split '\s+')[0]
if ([string]::IsNullOrWhiteSpace($Expected)) {
throw 'checksum entry for iop-node not found'
}
$Actual = (Get-FileHash -Algorithm SHA256 -Path $TmpNode).Hash.ToLowerInvariant()
if ($Actual -ne $Expected.ToLowerInvariant()) {
throw "checksum mismatch for iop-node: expected $Expected got $Actual"
}
$BinFile = Join-Path $InstallDir 'iop-node.exe'
Copy-Item -Force $TmpNode $BinFile
@"
transport:
edge_addr: "$EdgeAddr"
token: "$Token"
logging:
level: "info"
pretty: true
path: "$LogFileYAML"
metrics:
port: 19104
"@ | Set-Content -Encoding UTF8 -Path $ConfigFile
$Process = Start-Process -FilePath $BinFile -ArgumentList @('serve', '--config', $ConfigFile) -RedirectStandardOutput $StdoutLog -RedirectStandardError $StderrLog -WindowStyle Hidden -PassThru
"[iop-bootstrap] version=$Version target=$Target pid=$($Process.Id)"
"[iop-bootstrap] config=$ConfigFile log=$LogFile"
} finally {
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
}
}
`
}