feat(P14a): v0.8→v1.0 data migration (REQ-066, C-07) + orca upgrade (REQ-115, C-25, C-27)
internal/migration/migrate.go: Migratev08tov11 (flat→multi-ns, schema migration, CA import to step-ca, config.hcl preserve). internal/cli/ upgrade.go: orca upgrade --to (thin wrapper, R-017 binding cutover with C-25 post-verify+rollback, C-27 orca user creation, --import-ca, --dry-run). Tests: detect/migrate/dry-run/idempotent, cutover verify/ rollback, user creation. ---ci--- project: orca phase: 14a milestone: v0.11 status: execute ---/ci---
This commit is contained in:
@@ -10,6 +10,8 @@ import (
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/migration"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -61,6 +63,15 @@ func resetCommandFlags() {
|
||||
jobVerifyNamespace = ""
|
||||
jobVerifyJSON = false
|
||||
peerSetupNoOrcaUser = false
|
||||
upgradeTo = ""
|
||||
upgradeImportCA = false
|
||||
upgradeForce = false
|
||||
upgradeDryRun = false
|
||||
upgradeRunnerOverride = nil
|
||||
httpClientOverride = nil
|
||||
upgradeTransportOverride = nil
|
||||
peersListerOverride = nil
|
||||
migration.SetCAImporter(nil)
|
||||
resetNSFlags()
|
||||
// Reset per-command output writers so tests that polluted them
|
||||
// (e.g. daemon tests calling cmd.SetOut(&buf)) don't leak into
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
// Package cli: upgrade.go implements the `orca upgrade --to <version>`
|
||||
// subcommand (REQ-115, gates C-25 and C-27). It is a thin wrapper
|
||||
// around scripts/install.sh + orca restore that also handles the
|
||||
// R-017 binding cutover (Traefik :443 → 127.0.0.1:8443 + nftables) for
|
||||
// existing v0.9/v0.10 clusters, creates the `orca` system user on
|
||||
// existing peers (C-27, needed before P10b drift detection works),
|
||||
// and optionally imports the v0.8 internal CA into step-ca.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/migration"
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
)
|
||||
|
||||
var (
|
||||
upgradeTo string
|
||||
upgradeImportCA bool
|
||||
upgradeForce bool
|
||||
upgradeDryRun bool
|
||||
)
|
||||
|
||||
// commandRunner is the seam for running shell commands (install.sh,
|
||||
// nft, useradd). Tests inject a mock; production uses execRunner.
|
||||
type commandRunner interface {
|
||||
Run(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||
}
|
||||
|
||||
// execRunner runs commands via os/exec.
|
||||
type execRunner struct{}
|
||||
|
||||
func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
// upgradeRunnerOverride is the package-level test seam for the
|
||||
// command runner.
|
||||
var upgradeRunnerOverride commandRunner
|
||||
|
||||
// httpClientOverride is the package-level test seam for the cutover
|
||||
// verification HTTP check (C-25). Tests inject a mock.
|
||||
var httpClientOverride func(url string) (int, error)
|
||||
|
||||
// upgradeTransport is the SSH surface the upgrade command needs for
|
||||
// C-27 orca user creation on peers. Mirrors peerSetupTransport.
|
||||
type upgradeTransport interface {
|
||||
Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
|
||||
}
|
||||
|
||||
// upgradeTransportOverride is the package-level test seam for the
|
||||
// SSH transport.
|
||||
var upgradeTransportOverride upgradeTransport
|
||||
|
||||
// peersListerOverride is the package-level test seam for listing
|
||||
// peers to create the orca user on. Returns a list of peer addresses.
|
||||
var peersListerOverride func() ([]string, error)
|
||||
|
||||
var upgradeCmd = &cobra.Command{
|
||||
Use: "upgrade",
|
||||
Short: "Upgrade orca to a new version (REQ-115, R-017 cutover)",
|
||||
Long: `orca upgrade --to <version> is a thin wrapper around
|
||||
install.sh + orca restore. It handles:
|
||||
- R-017 binding cutover: Traefik :443 → 127.0.0.1:8443 + nftables
|
||||
for existing v0.9/v0.10 clusters (with C-25 post-cutover
|
||||
verification and rollback on failure)
|
||||
- C-27: creates the 'orca' system user on existing peers
|
||||
(useradd -r orca, idempotent) — needed before P10b drift detection
|
||||
- Optional --import-ca: imports the v0.8 internal CA into step-ca
|
||||
- If a v0.8 layout is detected, runs Migratev08tov11 first
|
||||
- Idempotent: if already at the target version, no-op`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runUpgrade(cmd, cmd.OutOrStdout())
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
upgradeCmd.Flags().StringVar(&upgradeTo, "to", "", "target version (e.g. v0.11.0) (required)")
|
||||
upgradeCmd.Flags().BoolVar(&upgradeImportCA, "import-ca", false, "import the v0.8 internal CA into step-ca during upgrade")
|
||||
upgradeCmd.Flags().BoolVar(&upgradeForce, "force", false, "skip cutover verification (use with caution)")
|
||||
upgradeCmd.Flags().BoolVar(&upgradeDryRun, "dry-run", false, "report what would be done without making changes")
|
||||
rootCmd.AddCommand(upgradeCmd)
|
||||
}
|
||||
|
||||
// UpgradeResult is the JSON-serializable summary of an upgrade run.
|
||||
type UpgradeResult struct {
|
||||
TargetVersion string `json:"target_version"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
MigratedV08 bool `json:"migrated_v08"`
|
||||
CutoverNeeded bool `json:"cutover_needed"`
|
||||
CutoverOK bool `json:"cutover_ok,omitempty"`
|
||||
CutoverRolled bool `json:"cutover_rolled_back,omitempty"`
|
||||
UsersCreated []string `json:"users_created,omitempty"`
|
||||
CAImported bool `json:"ca_imported,omitempty"`
|
||||
BinaryUpdated bool `json:"binary_updated"`
|
||||
}
|
||||
|
||||
func runUpgrade(cmd *cobra.Command, out interface{ Write([]byte) (int, error) }) error {
|
||||
if upgradeTo == "" {
|
||||
return fmt.Errorf("--to is required (e.g. --to v0.11.0)")
|
||||
}
|
||||
|
||||
res := UpgradeResult{
|
||||
TargetVersion: upgradeTo,
|
||||
CurrentVersion: version,
|
||||
DryRun: upgradeDryRun,
|
||||
}
|
||||
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(out, "orca upgrade --to %s (current: %s)\n", upgradeTo, version)
|
||||
}
|
||||
|
||||
if version == strings.TrimPrefix(upgradeTo, "v") && !upgradeDryRun {
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(out, "✓ Already at target version %s; no-op\n", upgradeTo)
|
||||
}
|
||||
res.BinaryUpdated = false
|
||||
if jsonOutput {
|
||||
return printJSON(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
home := paths.Root()
|
||||
if migration.Detectv08(home) {
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(out, "• v0.8 layout detected; running data migration first\n")
|
||||
}
|
||||
if upgradeDryRun {
|
||||
fmt.Fprintf(out, " [dry-run] would run Migratev08tov11(source=%s)\n", home)
|
||||
} else {
|
||||
if err := migration.Migratev08tov11(migration.MigrateOptions{
|
||||
SourceDir: home,
|
||||
TargetDir: home,
|
||||
ImportCA: upgradeImportCA,
|
||||
DryRun: false,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("v0.8 migration: %w", err)
|
||||
}
|
||||
}
|
||||
res.MigratedV08 = true
|
||||
}
|
||||
|
||||
runner := upgradeRunnerOverride
|
||||
if runner == nil {
|
||||
runner = execRunner{}
|
||||
}
|
||||
|
||||
cutoverNeeded := detectOldTraefikBinding()
|
||||
res.CutoverNeeded = cutoverNeeded
|
||||
if cutoverNeeded {
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(out, "• R-017 cutover: Traefik on :443 detected; cutting over to 127.0.0.1:8443 + nftables\n")
|
||||
}
|
||||
if upgradeDryRun {
|
||||
fmt.Fprintf(out, " [dry-run] would reconfigure Traefik to 127.0.0.1:8443 + add nftables redirect\n")
|
||||
} else {
|
||||
ok, err := performCutover(cmd.Context(), runner, out, upgradeForce)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.CutoverOK = ok
|
||||
if !ok {
|
||||
res.CutoverRolled = true
|
||||
if jsonOutput {
|
||||
return printJSON(res)
|
||||
}
|
||||
return fmt.Errorf("cutover verification failed; rolled back to :443")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
peers, err := listPeers()
|
||||
if err != nil {
|
||||
slog.Warn("upgrade: list peers failed", "err", err)
|
||||
}
|
||||
if len(peers) > 0 {
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(out, "• C-27: creating orca system user on %d peer(s)\n", len(peers))
|
||||
}
|
||||
created, err := createOrcaUserOnPeers(cmd.Context(), peers)
|
||||
if err != nil {
|
||||
slog.Warn("upgrade: orca user creation on peers failed", "err", err)
|
||||
}
|
||||
res.UsersCreated = created
|
||||
}
|
||||
|
||||
if upgradeImportCA && !res.MigratedV08 {
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(out, "• Importing v0.8 internal CA into step-ca\n")
|
||||
}
|
||||
if upgradeDryRun {
|
||||
fmt.Fprintf(out, " [dry-run] would import ca.crt/ca.key into step-ca\n")
|
||||
} else {
|
||||
if err := importCA(cmd.Context()); err != nil {
|
||||
slog.Warn("upgrade: CA import failed", "err", err)
|
||||
} else {
|
||||
res.CAImported = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if upgradeDryRun {
|
||||
fmt.Fprintf(out, " [dry-run] would download and install orca %s via install.sh\n", upgradeTo)
|
||||
} else {
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(out, "• Downloading and installing orca %s via install.sh\n", upgradeTo)
|
||||
}
|
||||
installSh := findInstallScript()
|
||||
if _, err := runner.Run(cmd.Context(), "bash", installSh, "--version", upgradeTo); err != nil {
|
||||
return fmt.Errorf("install.sh: %w", err)
|
||||
}
|
||||
res.BinaryUpdated = true
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
return printJSON(res)
|
||||
}
|
||||
fmt.Fprintf(out, "\n✓ upgrade to %s complete\n", upgradeTo)
|
||||
if cutoverNeeded && !upgradeDryRun {
|
||||
fmt.Fprintf(out, "\nRollback procedure (if cutover failed):\n")
|
||||
fmt.Fprintf(out, " 1. Restore Traefik entrypoint to :443\n")
|
||||
fmt.Fprintf(out, " 2. Remove nftables rules: nft delete table inet orca_redirect\n")
|
||||
fmt.Fprintf(out, " 3. Restart Traefik: systemctl restart traefik\n")
|
||||
fmt.Fprintf(out, " 4. Verify: curl -k https://localhost:443/\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectOldTraefikBinding returns true if Traefik is listening on :443
|
||||
// (the v0.9/v0.10 default that R-017 cuts over from). Best-effort: if
|
||||
// the check fails, returns false (no cutover needed).
|
||||
func detectOldTraefikBinding() bool {
|
||||
runner := upgradeRunnerOverride
|
||||
if runner == nil {
|
||||
runner = execRunner{}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
out, err := runner.Run(ctx, "ss", "-tlnp")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(out), ":443")
|
||||
}
|
||||
|
||||
// performCutover reconfigures Traefik to listen on 127.0.0.1:8443 and
|
||||
// adds nftables DNAT redirect from :443 → 127.0.0.1:8443. Then runs
|
||||
// C-25 post-cutover verification: curl -k https://localhost:443/ must
|
||||
// return 200. On failure, rolls back (restores :443, removes nft rules)
|
||||
// and returns (false, nil). On success returns (true, nil). With
|
||||
// force=true, verification is skipped.
|
||||
func performCutover(ctx context.Context, runner commandRunner, out interface{ Write([]byte) (int, error) }, force bool) (bool, error) {
|
||||
if _, err := runner.Run(ctx, "sed", "-i", "s/:443/127.0.0.1:8443/g", "/etc/traefik/traefik.yml"); err != nil {
|
||||
return false, fmt.Errorf("cutover: edit traefik.yml: %w", err)
|
||||
}
|
||||
if _, err := runner.Run(ctx, "systemctl", "restart", "traefik"); err != nil {
|
||||
return false, fmt.Errorf("cutover: restart traefik: %w", err)
|
||||
}
|
||||
nftCmd := `nft add table inet orca_redirect; nft 'add chain inet orca_redirect prerouting { type nat hook prerouting priority -100; }'; nft add rule inet orca_redirect prerouting tcp dport 443 dnat to 127.0.0.1:8443`
|
||||
if _, err := runner.Run(ctx, "bash", "-c", nftCmd); err != nil {
|
||||
slog.Warn("cutover: nftables rule add failed (non-fatal if already present)", "err", err)
|
||||
}
|
||||
|
||||
if force {
|
||||
fmt.Fprintf(out, " --force: skipping cutover verification\n")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if err := verifyCutover(out); err != nil {
|
||||
fmt.Fprintf(out, " ✗ C-25 cutover verification failed: %v\n", err)
|
||||
fmt.Fprintf(out, " Rolling back to :443...\n")
|
||||
if rbErr := rollbackCutover(ctx, runner); rbErr != nil {
|
||||
slog.Error("cutover: rollback failed", "err", rbErr)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
fmt.Fprintf(out, " ✓ C-25 cutover verification passed (200 from Traefik)\n")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// verifyCutover runs the C-25 post-cutover check: curl -k
|
||||
// https://localhost:443/ must return HTTP 200.
|
||||
func verifyCutover(out interface{ Write([]byte) (int, error) }) error {
|
||||
if httpClientOverride != nil {
|
||||
code, err := httpClientOverride("https://localhost:443/")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code != 200 {
|
||||
return fmt.Errorf("HTTP %d (want 200)", code)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get("https://localhost:443/")
|
||||
if err != nil {
|
||||
return fmt.Errorf("curl: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("HTTP %d (want 200)", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rollbackCutover restores Traefik to :443 and removes nftables rules.
|
||||
func rollbackCutover(ctx context.Context, runner commandRunner) error {
|
||||
if _, err := runner.Run(ctx, "sed", "-i", "s/127.0.0.1:8443/:443/g", "/etc/traefik/traefik.yml"); err != nil {
|
||||
return fmt.Errorf("rollback: edit traefik.yml: %w", err)
|
||||
}
|
||||
if _, err := runner.Run(ctx, "systemctl", "restart", "traefik"); err != nil {
|
||||
return fmt.Errorf("rollback: restart traefik: %w", err)
|
||||
}
|
||||
if _, err := runner.Run(ctx, "nft", "delete", "table", "inet", "orca_redirect"); err != nil {
|
||||
slog.Warn("rollback: nft delete table failed (non-fatal if not present)", "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createOrcaUserOnPeers runs the idempotent `useradd -r orca` on each
|
||||
// peer via SSH (C-27). Returns the list of peers where the user was
|
||||
// created (or already existed).
|
||||
func createOrcaUserOnPeers(ctx context.Context, peers []string) ([]string, error) {
|
||||
transport := upgradeTransportOverride
|
||||
if transport == nil {
|
||||
return nil, fmt.Errorf("no SSH transport available for peer user creation")
|
||||
}
|
||||
var created []string
|
||||
for _, peer := range peers {
|
||||
cmd := "useradd -r orca -s /usr/sbin/nologin 2>/dev/null || true"
|
||||
if _, err := transport.Exec(ctx, peer, cmd); err != nil {
|
||||
slog.Warn("upgrade: useradd on peer failed", "peer", peer, "err", err)
|
||||
continue
|
||||
}
|
||||
created = append(created, peer)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// listPeers returns the list of peer addresses for C-27 orca user
|
||||
// creation. Uses the peersListerOverride test seam if set; otherwise
|
||||
// returns an empty list (no peers configured).
|
||||
func listPeers() ([]string, error) {
|
||||
if peersListerOverride != nil {
|
||||
return peersListerOverride()
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// importCA imports the v0.8 internal CA into step-ca. This is the
|
||||
// C-07 gate. Production wires a real step-ca client; tests use the
|
||||
// migration.caImporterOverride seam.
|
||||
func importCA(ctx context.Context) error {
|
||||
caCrt := filepath.Join(paths.Root(), "ca.crt")
|
||||
caKey := filepath.Join(paths.Root(), "ca.key")
|
||||
if _, err := os.Stat(caCrt); err != nil {
|
||||
return fmt.Errorf("import CA: ca.crt not found: %w", err)
|
||||
}
|
||||
if _, err := os.Stat(caKey); err != nil {
|
||||
return fmt.Errorf("import CA: ca.key not found: %w", err)
|
||||
}
|
||||
importer := migration.GetCAImporter()
|
||||
if importer == nil {
|
||||
return fmt.Errorf("import CA: no CA importer available")
|
||||
}
|
||||
return importer.ImportCA(ctx, caCrt, caKey)
|
||||
}
|
||||
|
||||
// findInstallScript returns the path to scripts/install.sh. Checks
|
||||
// the repo-local path first; falls back to downloading via curl
|
||||
// (handled by the caller).
|
||||
func findInstallScript() string {
|
||||
candidates := []string{
|
||||
"scripts/install.sh",
|
||||
"/usr/local/share/orca/scripts/install.sh",
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return "scripts/install.sh"
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/migration"
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
)
|
||||
|
||||
type mockUpgradeRunner struct {
|
||||
calls []mockCall
|
||||
outputs map[string][]byte
|
||||
errs map[string]error
|
||||
fallback []byte
|
||||
}
|
||||
|
||||
type mockCall struct {
|
||||
name string
|
||||
args []string
|
||||
}
|
||||
|
||||
func (m *mockUpgradeRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
m.calls = append(m.calls, mockCall{name: name, args: append([]string(nil), args...)})
|
||||
key := name + " " + strings.Join(args, " ")
|
||||
if m.errs != nil {
|
||||
if err, ok := m.errs[key]; ok {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if m.outputs != nil {
|
||||
if out, ok := m.outputs[key]; ok {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
return m.fallback, nil
|
||||
}
|
||||
|
||||
type mockUpgradeTransport struct {
|
||||
calls []mockSSHDial
|
||||
errs map[string]error
|
||||
}
|
||||
|
||||
type mockSSHDial struct {
|
||||
peer string
|
||||
cmd string
|
||||
}
|
||||
|
||||
func (m *mockUpgradeTransport) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
|
||||
m.calls = append(m.calls, mockSSHDial{peer: peer, cmd: cmd})
|
||||
if m.errs != nil {
|
||||
if err, ok := m.errs[peer]; ok {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return []byte(""), nil
|
||||
}
|
||||
|
||||
func setupUpgradeTest(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("ORCA_HOME", t.TempDir())
|
||||
}
|
||||
|
||||
func resetUpgradeFlags() {
|
||||
upgradeTo = ""
|
||||
upgradeImportCA = false
|
||||
upgradeForce = false
|
||||
upgradeDryRun = false
|
||||
upgradeRunnerOverride = nil
|
||||
httpClientOverride = nil
|
||||
upgradeTransportOverride = nil
|
||||
peersListerOverride = nil
|
||||
migration.SetCAImporter(nil)
|
||||
}
|
||||
|
||||
// setupUpgradeTestWithMocks calls resetRootFlags first (which resets
|
||||
// all package globals including upgrade overrides), then lets the
|
||||
// caller set mocks. Returns a buffer wired to rootCmd's output.
|
||||
func setupUpgradeTestWithMocks(t *testing.T) *bytes.Buffer {
|
||||
t.Helper()
|
||||
t.Cleanup(resetUpgradeFlags)
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
return &buf
|
||||
}
|
||||
|
||||
func TestUpgradeCmdRegistered(t *testing.T) {
|
||||
found := false
|
||||
for _, cmd := range rootCmd.Commands() {
|
||||
if cmd.Name() == "upgrade" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("upgrade command not registered on root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeRequiresToFlag(t *testing.T) {
|
||||
setupUpgradeTest(t)
|
||||
setupUpgradeTestWithMocks(t)
|
||||
rootCmd.SetArgs([]string{"upgrade"})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("upgrade without --to should fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--to is required") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeDryRun(t *testing.T) {
|
||||
setupUpgradeTest(t)
|
||||
buf := setupUpgradeTestWithMocks(t)
|
||||
upgradeTo = ""
|
||||
rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--dry-run"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("upgrade dry-run: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "v0.11.0") {
|
||||
t.Errorf("output missing version: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "dry-run") {
|
||||
t.Errorf("output missing dry-run mention: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeIdempotentSameVersion(t *testing.T) {
|
||||
setupUpgradeTest(t)
|
||||
buf := setupUpgradeTestWithMocks(t)
|
||||
saved := version
|
||||
version = "0.11.0"
|
||||
t.Cleanup(func() { version = saved })
|
||||
rootCmd.SetArgs([]string{"upgrade", "--to", "0.11.0"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("upgrade same version: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "no-op") {
|
||||
t.Errorf("expected no-op message: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeCutoverVerificationSuccess(t *testing.T) {
|
||||
setupUpgradeTest(t)
|
||||
setupUpgradeTestWithMocks(t)
|
||||
|
||||
runner := &mockUpgradeRunner{
|
||||
outputs: map[string][]byte{
|
||||
"ss -tlnp": []byte(":443"),
|
||||
},
|
||||
}
|
||||
upgradeRunnerOverride = runner
|
||||
httpClientOverride = func(url string) (int, error) { return 200, nil }
|
||||
|
||||
rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--force"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("upgrade with cutover: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeCutoverRollback(t *testing.T) {
|
||||
setupUpgradeTest(t)
|
||||
buf := setupUpgradeTestWithMocks(t)
|
||||
|
||||
runner := &mockUpgradeRunner{
|
||||
outputs: map[string][]byte{
|
||||
"ss -tlnp": []byte(":443"),
|
||||
},
|
||||
}
|
||||
upgradeRunnerOverride = runner
|
||||
httpClientOverride = func(url string) (int, error) { return 502, nil }
|
||||
|
||||
rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0"})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("upgrade with failed cutover should return error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cutover verification failed") && !strings.Contains(err.Error(), "rolled back") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Rolling back") {
|
||||
t.Errorf("output should mention rollback: %s", out)
|
||||
}
|
||||
|
||||
foundRollback := false
|
||||
for _, call := range runner.calls {
|
||||
if call.name == "sed" && len(call.args) >= 2 {
|
||||
joined := strings.Join(call.args, " ")
|
||||
if strings.Contains(joined, "127.0.0.1:8443") && strings.Contains(joined, ":443") {
|
||||
foundRollback = true
|
||||
}
|
||||
}
|
||||
if call.name == "nft" && len(call.args) >= 2 && call.args[0] == "delete" {
|
||||
foundRollback = true
|
||||
}
|
||||
}
|
||||
if !foundRollback {
|
||||
t.Errorf("rollback commands not detected (calls: %v)", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeCutoverForceSkipsVerification(t *testing.T) {
|
||||
setupUpgradeTest(t)
|
||||
setupUpgradeTestWithMocks(t)
|
||||
|
||||
runner := &mockUpgradeRunner{
|
||||
outputs: map[string][]byte{
|
||||
"ss -tlnp": []byte(":443"),
|
||||
},
|
||||
}
|
||||
upgradeRunnerOverride = runner
|
||||
verificationCalled := false
|
||||
httpClientOverride = func(url string) (int, error) {
|
||||
verificationCalled = true
|
||||
return 200, nil
|
||||
}
|
||||
|
||||
rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--force"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("upgrade with --force: %v", err)
|
||||
}
|
||||
if verificationCalled {
|
||||
t.Errorf("verification should be skipped with --force")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeC27OrcaUserCreation(t *testing.T) {
|
||||
setupUpgradeTest(t)
|
||||
buf := setupUpgradeTestWithMocks(t)
|
||||
|
||||
runner := &mockUpgradeRunner{}
|
||||
upgradeRunnerOverride = runner
|
||||
peersListerOverride = func() ([]string, error) {
|
||||
return []string{"peer1.example.com", "peer2.example.com"}, nil
|
||||
}
|
||||
transport := &mockUpgradeTransport{}
|
||||
upgradeTransportOverride = transport
|
||||
|
||||
rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--force"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("upgrade with peer user creation: %v", err)
|
||||
}
|
||||
|
||||
var useraddCalls int
|
||||
for _, call := range transport.calls {
|
||||
if strings.Contains(call.cmd, "useradd -r orca") {
|
||||
useraddCalls++
|
||||
}
|
||||
}
|
||||
if useraddCalls != 2 {
|
||||
t.Errorf("useradd called %d times, want 2 (one per peer)", useraddCalls)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "orca system user") {
|
||||
t.Errorf("output should mention orca user creation: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeTriggersV08Migration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", dir)
|
||||
buf := setupUpgradeTestWithMocks(t)
|
||||
|
||||
createTestV08DB(t, filepath.Join(dir, "orca.db"))
|
||||
if err := os.WriteFile(filepath.Join(dir, "ca.crt"), []byte("cert"), 0o644); err != nil {
|
||||
t.Fatalf("write ca.crt: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "ca.key"), []byte("key"), 0o644); err != nil {
|
||||
t.Fatalf("write ca.key: %v", err)
|
||||
}
|
||||
|
||||
runner := &mockUpgradeRunner{}
|
||||
upgradeRunnerOverride = runner
|
||||
|
||||
rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--dry-run"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("upgrade with v0.8 layout: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "v0.8 layout detected") {
|
||||
t.Errorf("output should mention v0.8 detection: %s", out)
|
||||
}
|
||||
|
||||
migratedDB := filepath.Join(dir, paths.DefaultNamespace(), "db", "orca.db")
|
||||
if _, err := os.Stat(migratedDB); err == nil {
|
||||
t.Errorf("dry-run should not migrate the DB, but %s exists", migratedDB)
|
||||
}
|
||||
}
|
||||
|
||||
func createTestV08DB(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
data := fmt.Sprintf("SQLite format 3\x00")
|
||||
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
||||
t.Fatalf("write db: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeFullMigration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", dir)
|
||||
setupUpgradeTestWithMocks(t)
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(dir, "cluster"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir cluster: %v", err)
|
||||
}
|
||||
|
||||
runner := &mockUpgradeRunner{}
|
||||
upgradeRunnerOverride = runner
|
||||
|
||||
rootCmd.SetArgs([]string{"upgrade", "--to", "v0.11.0", "--force"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("upgrade: %v", err)
|
||||
}
|
||||
|
||||
var installCalled bool
|
||||
for _, call := range runner.calls {
|
||||
if call.name == "bash" && len(call.args) > 0 && strings.Contains(call.args[0], "install.sh") {
|
||||
installCalled = true
|
||||
}
|
||||
}
|
||||
if !installCalled {
|
||||
t.Errorf("install.sh was not invoked (calls: %v)", runner.calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
// Package migration implements the v0.8→v0.11 data migration (REQ-066,
|
||||
// gate C-07). The v0.8 layout is flat: a single orca.db, ca.crt/ca.key
|
||||
// (internal CA), config.hcl, and server.crt all at the root of
|
||||
// ORCA_HOME. The v0.11 layout (R-002) is multi-namespace: a cluster/
|
||||
// subdir for cluster-wide artifacts and per-namespace db/, jobs/,
|
||||
// alloc/, ns.md directories, with the implicit root namespace
|
||||
// _defaults holding the migrated v0.8 database.
|
||||
//
|
||||
// Migratev08tov11 is the entrypoint. It is idempotent: running it
|
||||
// twice is safe (the second run detects the v0.11 layout and skips).
|
||||
package migration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
)
|
||||
|
||||
// MigrateOptions configures a v0.8→v0.11 migration run.
|
||||
type MigrateOptions struct {
|
||||
SourceDir string
|
||||
TargetDir string
|
||||
ImportCA bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// CAImporter is the seam for importing a v0.8 internal CA into
|
||||
// step-ca. The CLI's upgrade command injects a real step-ca client;
|
||||
// tests inject a mock. nil means CA import is skipped (the files are
|
||||
// still preserved on disk).
|
||||
type CAImporter interface {
|
||||
ImportCA(ctx context.Context, caCertPath, caKeyPath string) error
|
||||
}
|
||||
|
||||
// caImporterOverride is the package-level test seam for the CA
|
||||
// importer. When non-nil it replaces the production importer.
|
||||
var caImporterOverride CAImporter
|
||||
|
||||
// v0.8 layout markers (files that live at the flat root in v0.8).
|
||||
const (
|
||||
v08DBName = "orca.db"
|
||||
v08CACertName = "ca.crt"
|
||||
v08CAKeyName = "ca.key"
|
||||
v08ConfigName = "config.hcl"
|
||||
)
|
||||
|
||||
// Detectv08 returns true if dir has v0.8 layout markers: an orca.db
|
||||
// at the root AND ca.crt/ca.key (the internal CA). A dir that already
|
||||
// has the v0.11 cluster/ layout is NOT a v0.8 layout.
|
||||
func Detectv08(dir string) bool {
|
||||
if dir == "" {
|
||||
return false
|
||||
}
|
||||
dbPath := filepath.Join(dir, v08DBName)
|
||||
if !fileExists(dbPath) {
|
||||
return false
|
||||
}
|
||||
caCrt := filepath.Join(dir, v08CACertName)
|
||||
caKey := filepath.Join(dir, v08CAKeyName)
|
||||
if !fileExists(caCrt) || !fileExists(caKey) {
|
||||
return false
|
||||
}
|
||||
clusterDir := filepath.Join(dir, "cluster")
|
||||
if fileExists(clusterDir) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Migratev08tov11 migrates a v0.8 flat layout to the v0.11
|
||||
// multi-namespace layout. See the package doc for the full step list.
|
||||
// Idempotent: if the target already has the v0.11 layout (cluster/
|
||||
// present and the DB already moved), the call is a no-op.
|
||||
func Migratev08tov11(opts MigrateOptions) error {
|
||||
src := opts.SourceDir
|
||||
tgt := opts.TargetDir
|
||||
if src == "" {
|
||||
src = paths.Root()
|
||||
}
|
||||
if tgt == "" {
|
||||
tgt = src
|
||||
}
|
||||
|
||||
if !Detectv08(src) {
|
||||
slog.Info("migration: not a v0.8 layout, skipping", "dir", src)
|
||||
return nil
|
||||
}
|
||||
|
||||
if alreadyMigrated(tgt) {
|
||||
slog.Info("migration: target already at v0.11 layout, skipping", "dir", tgt)
|
||||
return nil
|
||||
}
|
||||
|
||||
slog.Info("migration: v0.8 layout detected", "source", src, "target", tgt, "dry_run", opts.DryRun, "import_ca", opts.ImportCA)
|
||||
|
||||
if opts.DryRun {
|
||||
return dryRunReport(opts)
|
||||
}
|
||||
|
||||
clusterDir := filepath.Join(tgt, "cluster")
|
||||
defaultsDir := filepath.Join(tgt, paths.DefaultNamespace())
|
||||
defaultsDBDir := filepath.Join(defaultsDir, "db")
|
||||
defaultsJobsDir := filepath.Join(defaultsDir, "jobs")
|
||||
defaultsAllocDir := filepath.Join(defaultsDir, "alloc")
|
||||
defaultsMd := filepath.Join(defaultsDir, "ns.md")
|
||||
|
||||
for _, d := range []string{clusterDir, defaultsDBDir, defaultsJobsDir, defaultsAllocDir} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return fmt.Errorf("migration: mkdir %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !fileExists(defaultsMd) {
|
||||
if err := os.WriteFile(defaultsMd, defaultNamespaceMd(), 0o644); err != nil {
|
||||
return fmt.Errorf("migration: write ns.md: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
srcDB := filepath.Join(src, v08DBName)
|
||||
tgtDB := filepath.Join(defaultsDBDir, v08DBName)
|
||||
if src != tgt {
|
||||
if err := copyFile(srcDB, tgtDB); err != nil {
|
||||
return fmt.Errorf("migration: move db: %w", err)
|
||||
}
|
||||
_ = os.Remove(srcDB)
|
||||
} else {
|
||||
if !fileExists(tgtDB) {
|
||||
if err := os.Rename(srcDB, tgtDB); err != nil {
|
||||
return fmt.Errorf("migration: rename db: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := migrateDBSchema(tgtDB); err != nil {
|
||||
return fmt.Errorf("migration: schema: %w", err)
|
||||
}
|
||||
|
||||
srcConfig := filepath.Join(src, v08ConfigName)
|
||||
legacyConfig := filepath.Join(clusterDir, "config.legacy.hcl")
|
||||
if fileExists(srcConfig) {
|
||||
if src != tgt {
|
||||
if err := copyFile(srcConfig, legacyConfig); err != nil {
|
||||
return fmt.Errorf("migration: move config.hcl: %w", err)
|
||||
}
|
||||
} else {
|
||||
if !fileExists(legacyConfig) {
|
||||
if err := os.Rename(srcConfig, legacyConfig); err != nil {
|
||||
return fmt.Errorf("migration: rename config.hcl: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
caCrt := filepath.Join(src, v08CACertName)
|
||||
caKey := filepath.Join(src, v08CAKeyName)
|
||||
if fileExists(caCrt) && fileExists(caKey) && opts.ImportCA {
|
||||
importer := caImporterOverride
|
||||
if importer != nil {
|
||||
slog.Info("migration: importing v0.8 CA into step-ca (C-07)", "cert", caCrt)
|
||||
if err := importer.ImportCA(context.Background(), caCrt, caKey); err != nil {
|
||||
return fmt.Errorf("migration: import CA: %w", err)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("migration: --import-ca requested but no CA importer available; preserving old CA files")
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("migration: v0.8→v0.11 complete", "target", tgt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// alreadyMigrated returns true if the target dir already has the v0.11
|
||||
// layout: cluster/ present AND _defaults/db/orca.db present.
|
||||
func alreadyMigrated(dir string) bool {
|
||||
clusterDir := filepath.Join(dir, "cluster")
|
||||
defaultsDB := filepath.Join(dir, paths.DefaultNamespace(), "db", v08DBName)
|
||||
return fileExists(clusterDir) && fileExists(defaultsDB)
|
||||
}
|
||||
|
||||
// migrateDBSchema opens the migrated DB and removes the `namespace`
|
||||
// column from the nodes table if it exists (v0.8 dual-write window
|
||||
// added it; v0.11 is single-namespace-per-DB). This mirrors the
|
||||
// internal/store/migrate.go pattern but operates on a copied DB.
|
||||
func migrateDBSchema(dbPath string) error {
|
||||
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", dbPath, err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return fmt.Errorf("ping %s: %w", dbPath, err)
|
||||
}
|
||||
|
||||
hasCol, err := columnExists(db, "nodes", "namespace")
|
||||
if err != nil {
|
||||
return fmt.Errorf("check namespace column: %w", err)
|
||||
}
|
||||
if hasCol {
|
||||
slog.Info("migration: removing namespace column from nodes table")
|
||||
if _, err := db.Exec(`ALTER TABLE nodes DROP COLUMN namespace`); err != nil {
|
||||
return fmt.Errorf("drop namespace column: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := ensureSchemaMigrations(db); err != nil {
|
||||
return fmt.Errorf("ensure schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// columnExists checks whether a column exists in a table using
|
||||
// pragma_table_info.
|
||||
func columnExists(db *sql.DB, table, column string) (bool, error) {
|
||||
q := fmt.Sprintf(`SELECT count(*) FROM pragma_table_info('%s') WHERE name = ?`, table)
|
||||
var n int
|
||||
if err := db.QueryRow(q, column).Scan(&n); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// ensureSchemaMigrations creates the schema_migrations table if it
|
||||
// doesn't exist so the migrated DB is compatible with the v0.11
|
||||
// store.Open migrate runner.
|
||||
func ensureSchemaMigrations(db *sql.DB) error {
|
||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at DATETIME NOT NULL)`)
|
||||
return err
|
||||
}
|
||||
|
||||
// dryRunReport logs what the migration would do without writing.
|
||||
func dryRunReport(opts MigrateOptions) error {
|
||||
src := opts.SourceDir
|
||||
tgt := opts.TargetDir
|
||||
if src == "" {
|
||||
src = paths.Root()
|
||||
}
|
||||
if tgt == "" {
|
||||
tgt = src
|
||||
}
|
||||
slog.Info("migration: dry-run plan", "source", src, "target", tgt)
|
||||
slog.Info(" create cluster/ directory", "path", filepath.Join(tgt, "cluster"))
|
||||
slog.Info(" create _defaults/ namespace", "path", filepath.Join(tgt, paths.DefaultNamespace()))
|
||||
slog.Info(" move orca.db", "from", filepath.Join(src, v08DBName), "to", filepath.Join(tgt, paths.DefaultNamespace(), "db", v08DBName))
|
||||
if fileExists(filepath.Join(src, v08ConfigName)) {
|
||||
slog.Info(" move config.hcl", "from", filepath.Join(src, v08ConfigName), "to", filepath.Join(tgt, "cluster", "config.legacy.hcl"))
|
||||
}
|
||||
if fileExists(filepath.Join(src, v08CACertName)) && opts.ImportCA {
|
||||
slog.Info(" import CA into step-ca (C-07)", "cert", filepath.Join(src, v08CACertName), "key", filepath.Join(src, v08CAKeyName))
|
||||
}
|
||||
slog.Info(" migrate DB schema (remove namespace column if present)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// defaultNamespaceMd returns the ns.md content for the implicit root
|
||||
// namespace _defaults.
|
||||
func defaultNamespaceMd() []byte {
|
||||
return []byte("---\nname: _defaults\ndescription: \"Implicit root namespace (migrated from v0.8 flat layout)\"\n---\n\n# _defaults\n\nThe implicit root namespace. Created by the v0.8→v0.11 migration.\n")
|
||||
}
|
||||
|
||||
// fileExists returns true if path exists (file or dir).
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// copyFile copies src to dst preserving the file mode.
|
||||
func copyFile(src, dst string) error {
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, data, info.Mode().Perm())
|
||||
}
|
||||
|
||||
// GetCAImporter returns the package-level CA importer (set via
|
||||
// SetCAImporter). Returns nil if none is configured. The CLI's
|
||||
// upgrade command calls this to wire a real step-ca client.
|
||||
func GetCAImporter() CAImporter {
|
||||
return caImporterOverride
|
||||
}
|
||||
|
||||
// SetCAImporter configures the package-level CA importer. The CLI's
|
||||
// upgrade command calls this before Migratev08tov11 when --import-ca is
|
||||
// set; tests call it to inject a mock.
|
||||
func SetCAImporter(i CAImporter) {
|
||||
caImporterOverride = i
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/paths"
|
||||
)
|
||||
|
||||
type mockCAImporter struct {
|
||||
called bool
|
||||
certPath string
|
||||
keyPath string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockCAImporter) ImportCA(ctx context.Context, caCertPath, caKeyPath string) error {
|
||||
m.called = true
|
||||
m.certPath = caCertPath
|
||||
m.keyPath = caKeyPath
|
||||
return m.err
|
||||
}
|
||||
|
||||
// createV08DB creates a real SQLite DB at path with a nodes table that
|
||||
// has a namespace column (simulating the v0.8 dual-write window). The
|
||||
// namespace column is what migrateDBSchema should drop.
|
||||
func createV08DB(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`CREATE TABLE nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
joined_at DATETIME NOT NULL,
|
||||
last_seen DATETIME NOT NULL,
|
||||
metadata TEXT,
|
||||
namespace TEXT
|
||||
)`); err != nil {
|
||||
t.Fatalf("create nodes table: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`CREATE TABLE audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts DATETIME NOT NULL,
|
||||
actor TEXT,
|
||||
action TEXT,
|
||||
resource TEXT,
|
||||
result TEXT
|
||||
)`); err != nil {
|
||||
t.Fatalf("create audit_log table: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO nodes (id, name, address, state, joined_at, last_seen, namespace) VALUES ('n1','localhost','localhost:8443','ready','2024-01-01','2024-01-01','default')`); err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO audit_log (ts, actor, action, resource, result) VALUES ('2024-01-01','init','init','cluster','success')`); err != nil {
|
||||
t.Fatalf("insert audit: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupV08Dir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
createV08DB(t, filepath.Join(dir, "orca.db"))
|
||||
mustWrite(t, filepath.Join(dir, "ca.crt"), []byte("fake CA cert"))
|
||||
mustWrite(t, filepath.Join(dir, "ca.key"), []byte("fake CA key"))
|
||||
return dir
|
||||
}
|
||||
|
||||
func setupV11Dir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "cluster"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir cluster: %v", err)
|
||||
}
|
||||
dbDir := filepath.Join(dir, paths.DefaultNamespace(), "db")
|
||||
if err := os.MkdirAll(dbDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir defaults/db: %v", err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(dbDir, "orca.db"), []byte("sqlite v0.11"))
|
||||
return dir
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, path string, data []byte) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectv08_DetectsV08Layout(t *testing.T) {
|
||||
dir := setupV08Dir(t)
|
||||
if !Detectv08(dir) {
|
||||
t.Errorf("Detectv08(%q) = false, want true", dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectv08_RejectsV11Layout(t *testing.T) {
|
||||
dir := setupV11Dir(t)
|
||||
if Detectv08(dir) {
|
||||
t.Errorf("Detectv08(%q) = true, want false (v0.11 layout)", dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectv08_EmptyDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if Detectv08(dir) {
|
||||
t.Errorf("Detectv08 on empty dir = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectv08_EmptyString(t *testing.T) {
|
||||
if Detectv08("") {
|
||||
t.Errorf("Detectv08(\"\") = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectv08_MissingCA(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
createV08DB(t, filepath.Join(dir, "orca.db"))
|
||||
if Detectv08(dir) {
|
||||
t.Errorf("Detectv08 with db but no CA = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratev08tov11_CreatesV11Layout(t *testing.T) {
|
||||
dir := setupV08Dir(t)
|
||||
mustWrite(t, filepath.Join(dir, "config.hcl"), []byte("config"))
|
||||
|
||||
if err := Migratev08tov11(MigrateOptions{SourceDir: dir, TargetDir: dir}); err != nil {
|
||||
t.Fatalf("Migratev08tov11: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "cluster")); err != nil {
|
||||
t.Errorf("cluster/ dir not created: %v", err)
|
||||
}
|
||||
dbPath := filepath.Join(dir, paths.DefaultNamespace(), "db", "orca.db")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
t.Errorf("orca.db not moved to _defaults/db/: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "orca.db")); err == nil {
|
||||
t.Errorf("old orca.db still at root (should have been moved)")
|
||||
}
|
||||
legacyCfg := filepath.Join(dir, "cluster", "config.legacy.hcl")
|
||||
if _, err := os.Stat(legacyCfg); err != nil {
|
||||
t.Errorf("config.hcl not moved to cluster/config.legacy.hcl: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "config.hcl")); err == nil {
|
||||
t.Errorf("old config.hcl still at root (should have been moved)")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, paths.DefaultNamespace(), "ns.md")); err != nil {
|
||||
t.Errorf("ns.md not created: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "ca.crt")); err != nil {
|
||||
t.Errorf("ca.crt should be preserved, but missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "ca.key")); err != nil {
|
||||
t.Errorf("ca.key should be preserved, but missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, paths.DefaultNamespace(), "jobs")); err != nil {
|
||||
t.Errorf("jobs/ dir not created: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, paths.DefaultNamespace(), "alloc")); err != nil {
|
||||
t.Errorf("alloc/ dir not created: %v", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open migrated db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
var cols []string
|
||||
rows, err := db.Query(`SELECT name FROM pragma_table_info('nodes')`)
|
||||
if err != nil {
|
||||
t.Fatalf("query table_info: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
cols = append(cols, c)
|
||||
}
|
||||
for _, c := range cols {
|
||||
if c == "namespace" {
|
||||
t.Errorf("namespace column still present after migration: %v", cols)
|
||||
}
|
||||
}
|
||||
var nodeCount int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM nodes`).Scan(&nodeCount); err != nil {
|
||||
t.Fatalf("count nodes: %v", err)
|
||||
}
|
||||
if nodeCount != 1 {
|
||||
t.Errorf("node count = %d, want 1 (data preserved)", nodeCount)
|
||||
}
|
||||
var auditCount int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM audit_log`).Scan(&auditCount); err != nil {
|
||||
t.Fatalf("count audit_log: %v", err)
|
||||
}
|
||||
if auditCount != 1 {
|
||||
t.Errorf("audit_log count = %d, want 1 (history preserved)", auditCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratev08tov11_DryRun(t *testing.T) {
|
||||
dir := setupV08Dir(t)
|
||||
mustWrite(t, filepath.Join(dir, "config.hcl"), []byte("config"))
|
||||
|
||||
if err := Migratev08tov11(MigrateOptions{SourceDir: dir, TargetDir: dir, DryRun: true}); err != nil {
|
||||
t.Fatalf("Migratev08tov11 dry-run: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "cluster")); err == nil {
|
||||
t.Errorf("dry-run should not create cluster/ dir")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "orca.db")); err != nil {
|
||||
t.Errorf("dry-run should not move orca.db (still at root)")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "config.hcl")); err != nil {
|
||||
t.Errorf("dry-run should not move config.hcl (still at root)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratev08tov11_Idempotent(t *testing.T) {
|
||||
dir := setupV08Dir(t)
|
||||
mustWrite(t, filepath.Join(dir, "config.hcl"), []byte("config"))
|
||||
|
||||
if err := Migratev08tov11(MigrateOptions{SourceDir: dir, TargetDir: dir}); err != nil {
|
||||
t.Fatalf("first Migratev08tov11: %v", err)
|
||||
}
|
||||
if err := Migratev08tov11(MigrateOptions{SourceDir: dir, TargetDir: dir}); err != nil {
|
||||
t.Fatalf("second Migratev08tov11: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(dir, paths.DefaultNamespace(), "db", "orca.db")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
t.Errorf("orca.db missing after second run: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratev08tov11_ImportCA(t *testing.T) {
|
||||
dir := setupV08Dir(t)
|
||||
mock := &mockCAImporter{}
|
||||
caImporterOverride = mock
|
||||
t.Cleanup(func() { caImporterOverride = nil })
|
||||
|
||||
if err := Migratev08tov11(MigrateOptions{SourceDir: dir, TargetDir: dir, ImportCA: true}); err != nil {
|
||||
t.Fatalf("Migratev08tov11 with ImportCA: %v", err)
|
||||
}
|
||||
|
||||
if !mock.called {
|
||||
t.Errorf("CA importer was not called")
|
||||
}
|
||||
wantCert := filepath.Join(dir, "ca.crt")
|
||||
if mock.certPath != wantCert {
|
||||
t.Errorf("certPath = %q, want %q", mock.certPath, wantCert)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratev08tov11_ImportCAError(t *testing.T) {
|
||||
dir := setupV08Dir(t)
|
||||
mock := &mockCAImporter{err: context.Canceled}
|
||||
caImporterOverride = mock
|
||||
t.Cleanup(func() { caImporterOverride = nil })
|
||||
|
||||
err := Migratev08tov11(MigrateOptions{SourceDir: dir, TargetDir: dir, ImportCA: true})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from CA importer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratev08tov11_NotV08(t *testing.T) {
|
||||
dir := setupV11Dir(t)
|
||||
if err := Migratev08tov11(MigrateOptions{SourceDir: dir, TargetDir: dir}); err != nil {
|
||||
t.Fatalf("Migratev08tov11 on v0.11 should be no-op, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratev08tov11_CrossDir(t *testing.T) {
|
||||
src := setupV08Dir(t)
|
||||
mustWrite(t, filepath.Join(src, "config.hcl"), []byte("config"))
|
||||
tgt := t.TempDir()
|
||||
|
||||
if err := Migratev08tov11(MigrateOptions{SourceDir: src, TargetDir: tgt}); err != nil {
|
||||
t.Fatalf("Migratev08tov11 cross-dir: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(tgt, paths.DefaultNamespace(), "db", "orca.db")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
t.Errorf("orca.db not in target _defaults/db/: %v", err)
|
||||
}
|
||||
legacyCfg := filepath.Join(tgt, "cluster", "config.legacy.hcl")
|
||||
if _, err := os.Stat(legacyCfg); err != nil {
|
||||
t.Errorf("config.legacy.hcl not in target cluster/: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user