mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
chore: merge main into feat/iso-installer, resolve Makefile .PHONY conflict
This commit is contained in:
@@ -180,9 +180,13 @@ jobs:
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- name: Clone and build
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: ./build-artifacts.sh "${GIT_BRANCH}" "${RUNNER_TEMP}/spinifex-artifacts.tar"
|
||||
run: |
|
||||
./build-install-artifacts.sh "${GIT_BRANCH}" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
|
||||
- name: Provision VMs
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
@@ -194,13 +198,14 @@ jobs:
|
||||
./scrub-env.sh env2 >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
|
||||
- name: Bootstrap Cluster
|
||||
- name: Bootstrap via Binary Install
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
SPX_PREBUILT=true \
|
||||
SPX_ARTIFACT_TAR="${RUNNER_TEMP}/spinifex-artifacts.tar" \
|
||||
SPX_EXTERNAL_POOL="192.168.1.240-192.168.1.250" \
|
||||
./bootstrap.sh env2 >> "$TOFU_LOG" 2>&1
|
||||
./bootstrap-install.sh env2 \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
|
||||
- name: Run E2E Tests
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
@@ -218,7 +223,7 @@ jobs:
|
||||
done
|
||||
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
|
||||
"cd ~/Development/mulga/spinifex && ./tests/e2e/run-multinode-e2e.sh ${NODE_IPS} && ./tests/e2e/run-cert-e2e.sh --multinode ${NODE_IPS}"
|
||||
"cd ~/e2e-tests && ./run-multinode-e2e.sh ${NODE_IPS} && ./run-cert-e2e.sh --multinode ${NODE_IPS}"
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
|
||||
@@ -22,6 +22,7 @@ linters:
|
||||
- errname # checks that sentinel errors are prefixed with Err, error types suffixed with Error
|
||||
- exptostd # detects golang.org/x/exp/ functions replaceable by stdlib
|
||||
- fatcontext # detects nested contexts in loops
|
||||
- forcetypeassert # flags unchecked type assertions that can panic
|
||||
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
|
||||
- goprintffuncname # checks that printf-like functions are named with f at the end
|
||||
- gosec # inspects source code for security problems
|
||||
@@ -29,11 +30,13 @@ linters:
|
||||
- ineffassign # detects assignments to existing variables that are not used
|
||||
- makezero # finds slice declarations with non-zero initial length followed by append
|
||||
- mirror # catches wrong mirror patterns in bytes/strings usage
|
||||
- musttag # checks structs passed to marshal/unmarshal have proper tags
|
||||
- modernize # modernizes code to use newer Go idioms (go fix analyzers)
|
||||
- nakedret # finds naked returns in functions
|
||||
- nilerr # catches code that returns nil even after checking err != nil
|
||||
- nilnesserr # catches returning a different nil error variable after err != nil check
|
||||
- nolintlint # reports ill-formed or insufficient nolint directives
|
||||
- nosprintfhostport # flags fmt.Sprintf host:port — use net.JoinHostPort for IPv6 safety
|
||||
- predeclared # catches shadowing of Go builtins (len, cap, error, etc.)
|
||||
- reassign # checks that package variables are not reassigned
|
||||
- recvcheck # checks for receiver type consistency (pointer vs value)
|
||||
|
||||
@@ -120,6 +120,17 @@ run:
|
||||
$(MAKE) go_build
|
||||
$(MAKE) go_run
|
||||
|
||||
# Fast iteration: build + install binary + restart all services
|
||||
deploy: build
|
||||
sudo install -m 755 bin/spx /usr/local/bin/spx
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart spinifex.target
|
||||
|
||||
# Re-run setup.sh after changing systemd units, helper scripts, or logrotate config.
|
||||
# Not needed for code-only changes — use deploy for those.
|
||||
reinstall:
|
||||
scripts/dev-install.sh
|
||||
|
||||
clean:
|
||||
rm -f ./bin/$(GO_PROJECT_NAME)
|
||||
rm -rf spinifex/services/spinifexui/frontend/dist
|
||||
@@ -136,9 +147,9 @@ install-system:
|
||||
ovn-central ovn-host openvswitch-switch dhcpcd-base
|
||||
|
||||
install-go:
|
||||
@echo -e "\n....Installing Go 1.26.1 for $(ARCH) ($(GO_ARCH))...."
|
||||
@echo -e "\n....Installing Go 1.26.2 for $(ARCH) ($(GO_ARCH))...."
|
||||
@if [ ! -d "/usr/local/go" ]; then \
|
||||
curl -L https://go.dev/dl/go1.26.1.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
|
||||
curl -L https://go.dev/dl/go1.26.2.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
|
||||
else \
|
||||
echo "Go already installed in /usr/local/go"; \
|
||||
fi
|
||||
@@ -211,7 +222,8 @@ distro-arm64:
|
||||
distro-clean:
|
||||
rm -rf dist/
|
||||
|
||||
.PHONY: build build-ui build-installer build-alb-agent build-system-image build-alb-image go_build go_run preflight test test-cover test-race diff-coverage bench run clean \
|
||||
.PHONY: build build-ui build-installer build-alb-agent build-system-image build-alb-image go_build go_run preflight test test-cover test-race diff-coverage bench run \
|
||||
deploy reinstall clean \
|
||||
install-system install-go install-aws quickinstall \
|
||||
lint fix govulncheck \
|
||||
distro distro-amd64 distro-arm64 distro-clean
|
||||
|
||||
@@ -6,7 +6,7 @@ Wants=spinifex-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
User=spinifex-gw
|
||||
Group=spinifex
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service awsgw start
|
||||
@@ -14,9 +14,40 @@ Restart=on-failure
|
||||
RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/awsgw/
|
||||
Environment=SPINIFEX_AWSGW_TLS_CERT=/etc/spinifex/server.pem
|
||||
Environment=SPINIFEX_AWSGW_TLS_KEY=/etc/spinifex/server.key
|
||||
|
||||
# Filesystem access
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key
|
||||
ReadWritePaths=/var/lib/spinifex/awsgw /var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
CapabilityBoundingSet=
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
|
||||
@@ -6,7 +6,7 @@ Wants=spinifex-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
User=spinifex-daemon
|
||||
Group=spinifex
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service spinifex start
|
||||
@@ -17,5 +17,37 @@ Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex/
|
||||
Environment=SPINIFEX_WAL_DIR=/var/lib/spinifex/spinifex/
|
||||
|
||||
# Filesystem access — NoNewPrivileges omitted (daemon uses sudo for ip, ovs-vsctl, dhcpcd)
|
||||
ProtectSystem=full
|
||||
PrivateDevices=no
|
||||
DevicePolicy=closed
|
||||
DeviceAllow=/dev/kvm rw
|
||||
DeviceAllow=/dev/net/tun rw
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/server.pem /etc/spinifex/server.key
|
||||
ReadWritePaths=/var/lib/spinifex/spinifex /var/log/spinifex /run/spinifex /run/spinifex/nbd
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
|
||||
@@ -5,7 +5,7 @@ Before=spinifex-predastore.service spinifex-daemon.service spinifex-awsgw.servic
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
User=spinifex-nats
|
||||
Group=spinifex
|
||||
ExecStart=/usr/local/bin/spx service nats start
|
||||
Restart=on-failure
|
||||
@@ -13,5 +13,36 @@ RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/nats/nats.conf
|
||||
|
||||
# Filesystem access
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/nats
|
||||
ReadWritePaths=/var/lib/spinifex/nats /var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
CapabilityBoundingSet=
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
|
||||
@@ -6,7 +6,7 @@ Wants=spinifex-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
User=spinifex-storage
|
||||
Group=spinifex
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/var/lib/spinifex/predastore-start.sh
|
||||
@@ -18,5 +18,36 @@ Environment=SPINIFEX_PREDASTORE_BASE_PATH=/var/lib/spinifex/predastore/
|
||||
Environment=SPINIFEX_PREDASTORE_TLS_CERT=/etc/spinifex/server.pem
|
||||
Environment=SPINIFEX_PREDASTORE_TLS_KEY=/etc/spinifex/server.key
|
||||
|
||||
# Filesystem access
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/predastore /etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key
|
||||
ReadWritePaths=/var/lib/spinifex/predastore /var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
CapabilityBoundingSet=
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
|
||||
@@ -5,7 +5,7 @@ After=spinifex-awsgw.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
User=spinifex-ui
|
||||
Group=spinifex
|
||||
ExecStart=/usr/local/bin/spx service spinifex-ui start
|
||||
Restart=on-failure
|
||||
@@ -14,5 +14,36 @@ OOMScoreAdjust=-500
|
||||
Environment=SPINIFEX_UI_TLS_CERT=/etc/spinifex/server.pem
|
||||
Environment=SPINIFEX_UI_TLS_KEY=/etc/spinifex/server.key
|
||||
|
||||
# Filesystem access
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/server.pem /etc/spinifex/server.key
|
||||
ReadWritePaths=/var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
CapabilityBoundingSet=
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
|
||||
@@ -6,7 +6,7 @@ Wants=spinifex-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
User=spinifex-viperblock
|
||||
Group=spinifex
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service viperblock start
|
||||
@@ -17,5 +17,36 @@ EnvironmentFile=/etc/spinifex/systemd.env
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/viperblock/
|
||||
|
||||
# Filesystem access
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/systemd.env
|
||||
ReadWritePaths=/var/lib/spinifex/viperblock /var/log/spinifex /run/spinifex/nbd
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
CapabilityBoundingSet=
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
|
||||
@@ -6,7 +6,7 @@ Wants=spinifex-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
User=spinifex-vpcd
|
||||
Group=spinifex
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service vpcd start
|
||||
@@ -14,6 +14,36 @@ Restart=on-failure
|
||||
RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/vpcd/
|
||||
|
||||
# Filesystem access — NoNewPrivileges omitted (vpcd uses sudo for ovs-vsctl, ovn-nbctl, systemctl)
|
||||
ProtectSystem=full
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml
|
||||
ReadWritePaths=/var/lib/spinifex/vpcd /var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
|
||||
+39
-43
@@ -561,9 +561,9 @@ func runimagesListCmd(cmd *cobra.Command, args []string) {
|
||||
// TODO: Move all logic to a module, use minimal application logic in viper commands
|
||||
func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
if os.Getuid() != 0 {
|
||||
fmt.Fprintln(os.Stderr, "⚠️ Not running as root — CA certificate will not be installed to system trust store.")
|
||||
fmt.Fprintln(os.Stderr, " Run with sudo, or manually: sudo cp ~/spinifex/config/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt && sudo update-ca-certificates")
|
||||
fmt.Fprintln(os.Stderr)
|
||||
fmt.Fprintln(os.Stderr, "⚠️ Warning: 'spx admin init' is not running as root.")
|
||||
fmt.Fprintln(os.Stderr, " Service user setup and CA certificate installation will be skipped.")
|
||||
fmt.Fprintln(os.Stderr, " For production deployments, run with sudo.")
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
@@ -800,14 +800,15 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
fmt.Fprintf(os.Stderr, "Error generating IAM master key: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
bootstrapResult, err := writeBootstrapFiles(configDir, masterKey, accessKey, secretKey, accountID)
|
||||
bootstrapDir := filepath.Join(spxRoot, "awsgw")
|
||||
bootstrapResult, err := writeBootstrapFiles(configDir, bootstrapDir, masterKey, accessKey, secretKey, accountID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing bootstrap files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("\n🔐 Generated IAM master key")
|
||||
fmt.Printf(" Master key: %s\n", filepath.Join(configDir, "master.key"))
|
||||
fmt.Printf(" Bootstrap: %s\n", filepath.Join(configDir, "bootstrap.json"))
|
||||
fmt.Printf(" Bootstrap: %s\n", filepath.Join(bootstrapDir, "bootstrap.json"))
|
||||
|
||||
fmt.Printf("\n🔑 Generated admin credentials (save these — they won't be shown again):\n")
|
||||
fmt.Printf(" Access Key: %s\n", bootstrapResult.AdminAccessKey)
|
||||
@@ -948,6 +949,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
Region: region,
|
||||
NatsToken: natsToken,
|
||||
DataDir: spxRoot,
|
||||
LogDir: LogDirFor(spxRoot),
|
||||
ConfigDir: configDir,
|
||||
|
||||
Node: node,
|
||||
@@ -1029,14 +1031,10 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
|
||||
admin.CreateServiceDirectories(spxRoot)
|
||||
|
||||
// In production layout (running as root), fix ownership so the service user
|
||||
// can read config and write data. SUDO_USER identifies the operator account.
|
||||
// In production layout (running as root), set per-service ownership on
|
||||
// directories and shared config files (root:spinifex with correct modes).
|
||||
if os.Getuid() == 0 {
|
||||
sudoUser := os.Getenv("SUDO_USER")
|
||||
if sudoUser != "" {
|
||||
admin.ChownRecursive(configDir, sudoUser)
|
||||
admin.ChownRecursive(spxRoot, sudoUser)
|
||||
}
|
||||
admin.SetServiceOwnership()
|
||||
}
|
||||
|
||||
// Print success message
|
||||
@@ -1066,13 +1064,14 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
|
||||
fmt.Fprintf(os.Stderr, "❌ Error generating IAM master key: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
bootstrapResult, err := writeBootstrapFiles(configDir, masterKey, accessKey, secretKey, accountID)
|
||||
bootstrapDir := filepath.Join(spxRoot, "awsgw")
|
||||
bootstrapResult, err := writeBootstrapFiles(configDir, bootstrapDir, masterKey, accessKey, secretKey, accountID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error writing bootstrap files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("\n🔐 Generated IAM master key")
|
||||
fmt.Printf(" Bootstrap: %s\n", filepath.Join(configDir, "bootstrap.json"))
|
||||
fmt.Printf(" Bootstrap: %s\n", filepath.Join(bootstrapDir, "bootstrap.json"))
|
||||
|
||||
// Read CA cert/key for distribution to joining nodes
|
||||
caCertData, err := os.ReadFile(filepath.Join(configDir, "ca.pem"))
|
||||
@@ -1186,6 +1185,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
|
||||
Region: region,
|
||||
NatsToken: natsToken,
|
||||
DataDir: spxRoot,
|
||||
LogDir: LogDirFor(spxRoot),
|
||||
ConfigDir: configDir,
|
||||
|
||||
Node: node,
|
||||
@@ -1261,14 +1261,10 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
|
||||
|
||||
admin.CreateServiceDirectories(spxRoot)
|
||||
|
||||
// In production layout (running as root), fix ownership so the service user
|
||||
// can read config and write data. SUDO_USER identifies the operator account.
|
||||
// In production layout (running as root), set per-service ownership on
|
||||
// directories and shared config files (root:spinifex with correct modes).
|
||||
if os.Getuid() == 0 {
|
||||
sudoUser := os.Getenv("SUDO_USER")
|
||||
if sudoUser != "" {
|
||||
admin.ChownRecursive(configDir, sudoUser)
|
||||
admin.ChownRecursive(spxRoot, sudoUser)
|
||||
}
|
||||
admin.SetServiceOwnership()
|
||||
}
|
||||
|
||||
// Keep formation server running briefly so joining nodes can fetch complete status
|
||||
@@ -1294,8 +1290,9 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
|
||||
|
||||
func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
if os.Getuid() != 0 {
|
||||
fmt.Fprintln(os.Stderr, "⚠️ Not running as root — CA certificate will not be installed to system trust store.")
|
||||
fmt.Fprintln(os.Stderr)
|
||||
fmt.Fprintln(os.Stderr, "⚠️ Warning: 'spx admin join' is not running as root.")
|
||||
fmt.Fprintln(os.Stderr, " Service user setup and CA certificate installation will be skipped.")
|
||||
fmt.Fprintln(os.Stderr, " For production deployments, run with sudo.")
|
||||
}
|
||||
|
||||
node, _ := cmd.Flags().GetString("node")
|
||||
@@ -1515,12 +1512,13 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error decoding master key: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := writeBootstrapFilesWithAdmin(configDir, masterKeyBytes, creds.AccessKey, creds.SecretKey, creds.AccountID, creds.AdminAccessKey, creds.AdminSecretKey); err != nil {
|
||||
bootstrapDir := filepath.Join(dataDir, "awsgw")
|
||||
if err := writeBootstrapFilesWithAdmin(configDir, bootstrapDir, masterKeyBytes, creds.AccessKey, creds.SecretKey, creds.AccountID, creds.AdminAccessKey, creds.AdminSecretKey); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error writing bootstrap files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("✅ IAM master key received from leader")
|
||||
fmt.Printf("✅ Bootstrap file written: %s\n", filepath.Join(configDir, "bootstrap.json"))
|
||||
fmt.Printf("✅ Bootstrap file written: %s\n", filepath.Join(bootstrapDir, "bootstrap.json"))
|
||||
|
||||
// Generate server cert signed by CA with this node's bind IP
|
||||
if err := admin.GenerateServerCertOnly(configDir, bindIP); err != nil {
|
||||
@@ -1584,6 +1582,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
Region: creds.Region,
|
||||
NatsToken: creds.NatsToken,
|
||||
DataDir: dataDir,
|
||||
LogDir: LogDirFor(dataDir),
|
||||
ConfigDir: configDir,
|
||||
|
||||
Node: node,
|
||||
@@ -1663,14 +1662,10 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
|
||||
admin.CreateServiceDirectories(dataDir)
|
||||
|
||||
// In production layout (running as root), fix ownership so the service user
|
||||
// can read config and write data. SUDO_USER identifies the operator account.
|
||||
// In production layout (running as root), set per-service ownership on
|
||||
// directories and shared config files (root:spinifex with correct modes).
|
||||
if os.Getuid() == 0 {
|
||||
sudoUser := os.Getenv("SUDO_USER")
|
||||
if sudoUser != "" {
|
||||
admin.ChownRecursive(configDir, sudoUser)
|
||||
admin.ChownRecursive(dataDir, sudoUser)
|
||||
}
|
||||
admin.SetServiceOwnership()
|
||||
}
|
||||
|
||||
// Print cluster summary
|
||||
@@ -1680,10 +1675,6 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
for name, n := range statusResp.Nodes {
|
||||
fmt.Printf(" - %s (%s)\n", name, n.BindIP)
|
||||
}
|
||||
fmt.Println("\n📋 Next steps:")
|
||||
fmt.Println(" 1. Start services:")
|
||||
fmt.Println(" ./scripts/start-dev.sh")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// buildRemoteNodes converts formation NodeInfo into RemoteNode entries,
|
||||
@@ -1816,7 +1807,7 @@ func runAccountCreate(cmd *cobra.Command, args []string) {
|
||||
endpointHost = h
|
||||
}
|
||||
}
|
||||
endpointURL := fmt.Sprintf("https://%s:9999", endpointHost)
|
||||
endpointURL := "https://" + net.JoinHostPort(endpointHost, "9999")
|
||||
|
||||
credPath := filepath.Join(homeDir, ".aws", "credentials")
|
||||
configPath := filepath.Join(homeDir, ".aws", "config")
|
||||
@@ -1917,8 +1908,9 @@ type writeBootstrapResult struct {
|
||||
}
|
||||
|
||||
// writeBootstrapFiles generates new admin credentials and writes the bootstrap
|
||||
// files (master.key + bootstrap.json). Used by init flows (single and multi-node).
|
||||
func writeBootstrapFiles(configDir string, masterKey []byte, accessKey, secretKey, accountID string) (*writeBootstrapResult, error) {
|
||||
// files (master.key to configDir, bootstrap.json to bootstrapDir).
|
||||
// Used by init flows (single and multi-node).
|
||||
func writeBootstrapFiles(configDir, bootstrapDir string, masterKey []byte, accessKey, secretKey, accountID string) (*writeBootstrapResult, error) {
|
||||
adminAccessKey, err := admin.GenerateAWSAccessKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate admin access key: %w", err)
|
||||
@@ -1927,7 +1919,7 @@ func writeBootstrapFiles(configDir string, masterKey []byte, accessKey, secretKe
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate admin secret key: %w", err)
|
||||
}
|
||||
if err := writeBootstrapFilesWithAdmin(configDir, masterKey, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey); err != nil {
|
||||
if err := writeBootstrapFilesWithAdmin(configDir, bootstrapDir, masterKey, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &writeBootstrapResult{
|
||||
@@ -1937,8 +1929,12 @@ func writeBootstrapFiles(configDir string, masterKey []byte, accessKey, secretKe
|
||||
}
|
||||
|
||||
// writeBootstrapFilesWithAdmin writes the bootstrap files using the provided
|
||||
// admin credentials. Used by join flows where admin creds come from the leader.
|
||||
func writeBootstrapFilesWithAdmin(configDir string, masterKey []byte, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey string) error {
|
||||
// admin credentials. master.key goes to configDir, bootstrap.json goes to
|
||||
// bootstrapDir (the awsgw data directory) so it stays outside /etc/spinifex.
|
||||
func writeBootstrapFilesWithAdmin(configDir, bootstrapDir string, masterKey []byte, accessKey, secretKey, accountID, adminAccessKey, adminSecretKey string) error {
|
||||
if err := os.MkdirAll(bootstrapDir, 0700); err != nil {
|
||||
return fmt.Errorf("create bootstrap directory %s: %w", bootstrapDir, err)
|
||||
}
|
||||
if err := handlers_iam.SaveMasterKey(filepath.Join(configDir, "master.key"), masterKey); err != nil {
|
||||
return fmt.Errorf("saving master key: %w", err)
|
||||
}
|
||||
@@ -1966,7 +1962,7 @@ func writeBootstrapFilesWithAdmin(configDir string, masterKey []byte, accessKey,
|
||||
},
|
||||
}
|
||||
|
||||
return handlers_iam.SaveBootstrapData(filepath.Join(configDir, "bootstrap.json"), bd)
|
||||
return handlers_iam.SaveBootstrapData(filepath.Join(bootstrapDir, "bootstrap.json"), bd)
|
||||
}
|
||||
|
||||
// detectedWanBridge returns the OVS bridge name that OVN bridge-mappings should
|
||||
|
||||
@@ -26,6 +26,16 @@ func DefaultDataDir() string {
|
||||
return filepath.Join(realUserHomeDir(), "spinifex")
|
||||
}
|
||||
|
||||
// LogDirFor returns the log directory for a given data directory.
|
||||
// Production: /var/log/spinifex (matches systemd ReadWritePaths)
|
||||
// Development: <dataDir>/logs (supports custom per-node data dirs)
|
||||
func LogDirFor(dataDir string) string {
|
||||
if isProductionLayout() {
|
||||
return "/var/log/spinifex"
|
||||
}
|
||||
return filepath.Join(dataDir, "logs")
|
||||
}
|
||||
|
||||
// realUserHomeDir returns the home directory of the real (non-sudo) user.
|
||||
// When running under sudo, SUDO_USER is set to the invoking user — resolve
|
||||
// their home directory so config/data land in the right place.
|
||||
|
||||
@@ -733,6 +733,12 @@ var vpcdStartCmd = &cobra.Command{
|
||||
}
|
||||
}
|
||||
|
||||
baseDir := viper.GetString("base-dir")
|
||||
if baseDir != "" {
|
||||
fmt.Println("Overwriting vpcd base-dir to:", baseDir)
|
||||
nodeConfig.BaseDir = baseDir
|
||||
}
|
||||
|
||||
svc, err := service.New("vpcd", &vpcd.Config{
|
||||
NatsHost: nodeConfig.NATS.Host,
|
||||
NatsToken: nodeConfig.NATS.ACL.Token,
|
||||
|
||||
@@ -40,7 +40,7 @@ jetstream {
|
||||
debug: false
|
||||
trace: false
|
||||
logtime: true
|
||||
log_file: "{{.DataDir}}/logs/nats.log"
|
||||
log_file: "{{.LogDir}}/nats.log"
|
||||
|
||||
# Limits
|
||||
max_connections: 1000
|
||||
|
||||
@@ -48,7 +48,7 @@ sudo apt install nbdkit nbdkit-plugin-dev pkg-config qemu-system qemu-utils qemu
|
||||
|
||||
**Note:** OVN and Open vSwitch are required for VPC networking (virtual switches, routers, DHCP, Geneve overlay). The packages are installed above, but the setup and configuration step (`setup-ovn.sh`) is covered later — see the [Setup OVN](#setup-ovn) section below.
|
||||
|
||||
Ensure the Go toolkit is installed for version 1.26.1 or higher. Recommended to install the latest directly from [https://go.dev/dl/](https://go.dev/dl/).
|
||||
Ensure the Go toolkit is installed for version 1.26.2 or higher. Recommended to install the latest directly from [https://go.dev/dl/](https://go.dev/dl/).
|
||||
|
||||
Confirm Go is correctly installed, and set in your $PATH.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ This guide builds Spinifex from source. For production deployments, the [binary
|
||||
**Requirements:**
|
||||
|
||||
- Ubuntu 22.04+ / Debian 12+
|
||||
- Go 1.26.1+
|
||||
- Go 1.26.2+
|
||||
- GCC, make, pkg-config
|
||||
- QEMU/KVM, OVN/Open vSwitch, AWS CLI v2, dhcpcd-base
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/mulgadc/spinifex
|
||||
|
||||
go 1.26.1
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go v1.55.8
|
||||
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/bin/bash
|
||||
# dev-install.sh — Full local development setup via production installer.
|
||||
# Builds from source, assembles a tarball, runs setup.sh for scaffolding
|
||||
# (user creation, directories, systemd units), initializes the cluster,
|
||||
# and starts services via systemd.
|
||||
#
|
||||
# Usage: ./scripts/dev-install.sh
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
MULGA_ROOT="$(cd "$PROJECT_ROOT/.." && pwd)"
|
||||
|
||||
echo "=== Building ==="
|
||||
cd "$PROJECT_ROOT" && make build
|
||||
cd "$MULGA_ROOT/viperblock" && make go_build_nbd
|
||||
|
||||
echo "=== Assembling tarball for setup.sh ==="
|
||||
STAGING=$(mktemp -d)
|
||||
trap 'rm -rf "$STAGING"' EXIT
|
||||
cp "$PROJECT_ROOT/bin/spx" "$STAGING/spx"
|
||||
cp "$MULGA_ROOT/viperblock/lib/nbdkit-viperblock-plugin.so" "$STAGING/"
|
||||
cp "$PROJECT_ROOT/scripts/setup-ovn.sh" "$STAGING/"
|
||||
mkdir -p "$STAGING/systemd"
|
||||
cp "$PROJECT_ROOT/build/systemd/"* "$STAGING/systemd/"
|
||||
mkdir -p "$STAGING/scripts"
|
||||
cp "$PROJECT_ROOT/build/scripts/"* "$STAGING/scripts/"
|
||||
cp "$PROJECT_ROOT/build/logrotate/spinifex" "$STAGING/logrotate-spinifex"
|
||||
tar czf /tmp/spinifex-local.tar.gz -C "$STAGING" .
|
||||
|
||||
echo "=== Cleaning stale state from previous installs ==="
|
||||
# Stop any running services before modifying files
|
||||
sudo systemctl stop spinifex.target 2>/dev/null || true
|
||||
sudo systemctl reset-failed 2>/dev/null || true
|
||||
|
||||
# Remove stale files owned by the dev user in production paths.
|
||||
# Previous dev-mode installs (admin init without sudo, start-dev.sh) leave
|
||||
# files owned by tf-user that service users (spinifex-nats, etc.) can't read
|
||||
# under systemd's ProtectSystem=strict sandboxing.
|
||||
for dir in /var/lib/spinifex /var/log/spinifex /etc/spinifex; do
|
||||
if [ -d "$dir" ]; then
|
||||
# Remove PID files, stale logs, and the legacy ~/spinifex/config symlink
|
||||
sudo find "$dir" -name '*.pid' -delete 2>/dev/null || true
|
||||
sudo find "$dir" -name '*.log' -delete 2>/dev/null || true
|
||||
sudo find "$dir" -name '*.log.*' -delete 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
# Remove legacy data dir contents that conflict with production layout
|
||||
if [ -d /var/lib/spinifex/config ]; then
|
||||
sudo rm -rf /var/lib/spinifex/config
|
||||
fi
|
||||
|
||||
echo "=== Running setup.sh (creates users, dirs, systemd units) ==="
|
||||
sudo INSTALL_SPINIFEX_TARBALL=/tmp/spinifex-local.tar.gz bash "$PROJECT_ROOT/scripts/setup.sh"
|
||||
rm -f /tmp/spinifex-local.tar.gz
|
||||
|
||||
echo "=== Setting up OVN ==="
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management
|
||||
|
||||
echo "=== Initializing ==="
|
||||
sudo spx admin init --force --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1
|
||||
|
||||
echo "=== Installing CA certificate ==="
|
||||
sudo cp /etc/spinifex/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
|
||||
sudo update-ca-certificates
|
||||
|
||||
echo "=== Starting services ==="
|
||||
sudo systemctl start spinifex.target
|
||||
|
||||
echo "=== Done ==="
|
||||
echo "Services: sudo systemctl status spinifex.target"
|
||||
echo "Logs: journalctl -u 'spinifex-*' -f"
|
||||
echo "Test: AWS_PROFILE=spinifex aws ec2 describe-instances"
|
||||
echo "Iterate: make deploy (rebuild + restart all)"
|
||||
|
||||
# Check if user needs to re-login for spinifex group membership
|
||||
if ! id -Gn 2>/dev/null | grep -qw spinifex; then
|
||||
echo ""
|
||||
echo "NOTE: You were added to the 'spinifex' group but your current"
|
||||
echo "session doesn't reflect it yet. Log out and back in, or run:"
|
||||
echo " newgrp spinifex"
|
||||
fi
|
||||
+10
-18
@@ -165,24 +165,16 @@ else
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 Development environment setup complete!"
|
||||
echo "Development environment setup complete!"
|
||||
echo ""
|
||||
echo "📋 Next steps:"
|
||||
echo "Recommended: Use scripts/dev-install.sh for a production-parity setup"
|
||||
echo " (systemd, privilege separation, production paths)."
|
||||
echo ""
|
||||
echo " 1. Setup OVN networking:"
|
||||
echo " ./scripts/setup-ovn.sh --management"
|
||||
echo " scripts/dev-install.sh # One-time: build, install, init, start via systemd"
|
||||
echo " make deploy # Daily: rebuild + copy binary + restart all"
|
||||
echo " make reinstall # Changed a .service file? Re-run full setup"
|
||||
echo ""
|
||||
echo " 2. Initialize Spinifex (creates ~/spinifex/ directories, config, certs, and installs CA):"
|
||||
echo " sudo ./bin/spx admin init --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1"
|
||||
echo ""
|
||||
echo " 3. Start services:"
|
||||
echo " ./scripts/start-dev.sh"
|
||||
echo ""
|
||||
echo "🔧 Development endpoints:"
|
||||
echo " - Spinifex Gateway: https://localhost:$SPINIFEX_GATEWAY_PORT"
|
||||
echo " - Predastore S3: https://localhost:$PREDASTORE_PORT"
|
||||
echo " - NATS: nats://localhost:$NATS_PORT"
|
||||
echo ""
|
||||
echo "🧪 Test with AWS CLI:"
|
||||
echo " export AWS_PROFILE=spinifex"
|
||||
echo " aws ec2 describe-instances"
|
||||
echo "Legacy workflow (deprecated, retained for pseudo-multinode CI only):"
|
||||
echo " ./scripts/setup-ovn.sh --management"
|
||||
echo " sudo ./bin/spx admin init --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1"
|
||||
echo " ./scripts/start-dev.sh"
|
||||
|
||||
@@ -7,7 +7,7 @@ Builds a Proxmox VM template with all Spinifex dependencies pre-installed. Cloni
|
||||
| Component | Details |
|
||||
|---|---|
|
||||
| System packages | QEMU, nbdkit, libvirt, gcc, jq, curl, etc. (`make install-system`) |
|
||||
| Go | 1.26.1 (`make install-go`) |
|
||||
| Go | 1.26.2 (`make install-go`) |
|
||||
| AWS CLI | v2 (`make install-aws`) |
|
||||
| Repositories | spinifex, viperblock, predastore (with `go.work` configured) |
|
||||
| Module cache | `go mod download` completed for all repos |
|
||||
|
||||
+38
-42
@@ -481,7 +481,19 @@ echo " ovn-encap-type: geneve"
|
||||
echo ""
|
||||
echo "Step 5: Starting ovn-controller..."
|
||||
|
||||
sudo systemctl start ovn-controller
|
||||
# Set ovn-controller file log level to WARN so it doesn't spam the log with
|
||||
# connection-retry INFO messages ("OVNSB commit failed") when the SB DB
|
||||
# isn't running. Uses a systemd ExecStartPost so it persists across restarts.
|
||||
OVN_CTRL_OVERRIDE="/etc/systemd/system/ovn-controller.service.d/log-level.conf"
|
||||
sudo mkdir -p "$(dirname "$OVN_CTRL_OVERRIDE")"
|
||||
sudo tee "$OVN_CTRL_OVERRIDE" >/dev/null <<'OVERRIDE'
|
||||
[Service]
|
||||
ExecStartPost=/bin/sh -c 'OVS_RUNDIR=/var/run/ovn exec /usr/bin/ovs-appctl -t ovn-controller vlog/set file:warn'
|
||||
OVERRIDE
|
||||
sudo systemctl daemon-reload
|
||||
echo " ovn-controller log level: file:warn (via systemd drop-in)"
|
||||
|
||||
sudo systemctl restart ovn-controller
|
||||
echo " ovn-controller: started"
|
||||
|
||||
# --- Step 6: Sysctl tuning ---
|
||||
@@ -565,59 +577,43 @@ else
|
||||
echo " systemd override: already exists"
|
||||
fi
|
||||
|
||||
# Create sudoers rule for network commands that always need root
|
||||
# (ip tuntap, ip link set — NET_ADMIN operations)
|
||||
# Sudoers rules for spinifex-daemon and spinifex-vpcd are managed by setup.sh
|
||||
# (install_sudoers). Skip writing here to avoid conflicts.
|
||||
SUDOERS_FILE="/etc/sudoers.d/spinifex-network"
|
||||
if [ ! -f "$SUDOERS_FILE" ]; then
|
||||
CURRENT_USER=$(whoami)
|
||||
sudo tee "$SUDOERS_FILE" >/dev/null <<EOF
|
||||
# Spinifex VPC networking: allow non-root daemon to manage tap devices and OVS
|
||||
$CURRENT_USER ALL=(root) NOPASSWD: /sbin/ip, /usr/sbin/ip
|
||||
$CURRENT_USER ALL=(root) NOPASSWD: /usr/bin/ovs-vsctl, /usr/bin/ovs-appctl
|
||||
$CURRENT_USER ALL=(root) NOPASSWD: /usr/bin/ovn-nbctl, /usr/bin/ovn-sbctl
|
||||
EOF
|
||||
sudo chmod 0440 "$SUDOERS_FILE"
|
||||
echo " sudoers rule: created ($SUDOERS_FILE)"
|
||||
if [ -f "$SUDOERS_FILE" ]; then
|
||||
echo " sudoers rule: already exists ($SUDOERS_FILE, managed by setup.sh)"
|
||||
else
|
||||
echo " sudoers rule: already exists"
|
||||
echo " sudoers rule: not found — run setup.sh first, or install manually"
|
||||
fi
|
||||
|
||||
# --- Step 9: Configure OVN log rotation ---
|
||||
# OVN has built-in rotation that renames foo.log → foo.log.log. A previous
|
||||
# logrotate config used a *.log glob that caught those .log.log files too,
|
||||
# creating .log.log.1 files that accumulated to 27GB. Fix: use explicit
|
||||
# filenames so logrotate only touches the primary logs, not OVN's backups.
|
||||
# The ovn-common package provides /etc/logrotate.d/ovn-common which handles
|
||||
# rotation and vlog/reopen. We just add maxsize + rotate to cap disk usage.
|
||||
echo ""
|
||||
echo "Step 9: Configuring OVN log rotation..."
|
||||
|
||||
# Clean up stale .log.log files from the old double-rotation bug
|
||||
if ls /var/log/ovn/*.log.log* 1>/dev/null 2>&1; then
|
||||
sudo rm -f /var/log/ovn/*.log.log*
|
||||
echo " cleaned up stale .log.log files"
|
||||
OVN_LOGROTATE="/etc/logrotate.d/ovn-common"
|
||||
if [ -f "$OVN_LOGROTATE" ]; then
|
||||
if ! grep -q 'maxsize' "$OVN_LOGROTATE"; then
|
||||
sudo sed -i '/^\/var\/log\/ovn\/\*\.log {/a\ rotate 5\n maxsize 100M' "$OVN_LOGROTATE"
|
||||
echo " added maxsize 100M + rotate 5 to $OVN_LOGROTATE"
|
||||
else
|
||||
echo " $OVN_LOGROTATE already has maxsize configured"
|
||||
fi
|
||||
else
|
||||
echo " WARNING: $OVN_LOGROTATE not found — install ovn-common package"
|
||||
fi
|
||||
|
||||
LOGROTATE_FILE="/etc/logrotate.d/ovn-spinifex"
|
||||
sudo tee "$LOGROTATE_FILE" >/dev/null <<'LOGROTATE'
|
||||
/var/log/ovn/ovn-controller.log
|
||||
/var/log/ovn/ovn-northd.log
|
||||
/var/log/ovn/ovsdb-server-nb.log
|
||||
/var/log/ovn/ovsdb-server-sb.log
|
||||
{
|
||||
daily
|
||||
rotate 3
|
||||
maxsize 100M
|
||||
compress
|
||||
missingok
|
||||
notifempty
|
||||
copytruncate
|
||||
}
|
||||
LOGROTATE
|
||||
echo " logrotate config: $LOGROTATE_FILE (daily, 100M max, 3 rotations, explicit filenames)"
|
||||
# Remove our old custom config if present (superseded by patching ovn-common)
|
||||
if [ -f /etc/logrotate.d/ovn-spinifex ]; then
|
||||
sudo rm -f /etc/logrotate.d/ovn-spinifex
|
||||
echo " removed obsolete /etc/logrotate.d/ovn-spinifex"
|
||||
fi
|
||||
|
||||
# --- Step 10: Enable auto-start on boot ---
|
||||
# OVN services should start with the system in production. ovn-controller uses
|
||||
# exponential backoff if the SB DB isn't ready yet, so CPU impact is negligible.
|
||||
# In dev, stop-dev.sh explicitly stops OVN when not needed.
|
||||
# OVN services should start with the system in production. ovn-controller
|
||||
# retries when the SB DB isn't ready; file log level is set to WARN (Step 5)
|
||||
# to prevent log spam during those retries.
|
||||
echo ""
|
||||
echo "Step 10: Enabling OVN auto-start on boot..."
|
||||
sudo systemctl enable openvswitch-switch 2>/dev/null || true
|
||||
|
||||
+114
-31
@@ -89,27 +89,66 @@ detect_arch() {
|
||||
info "Detected architecture: $MACHINE ($ARCH)"
|
||||
}
|
||||
|
||||
# --- Detect service user ---
|
||||
detect_service_user() {
|
||||
# Use the real user — either the current user, or SUDO_USER if running via sudo
|
||||
SPINIFEX_USER="${SUDO_USER:-$(whoami)}"
|
||||
SPINIFEX_GROUP="$(id -gn "$SPINIFEX_USER")"
|
||||
# --- Create per-service system users ---
|
||||
create_service_users() {
|
||||
SPINIFEX_GROUP="spinifex"
|
||||
|
||||
if [ "$SPINIFEX_USER" = "root" ]; then
|
||||
fatal "Cannot determine service user. Do not run as root directly — use: curl ... | bash (as a normal user)"
|
||||
# Create shared group
|
||||
if ! getent group "$SPINIFEX_GROUP" > /dev/null 2>&1; then
|
||||
$SUDO groupadd --system "$SPINIFEX_GROUP"
|
||||
fi
|
||||
|
||||
info "Services will run as: $SPINIFEX_USER:$SPINIFEX_GROUP"
|
||||
# Create per-service users with correct home directories
|
||||
declare -A SERVICE_HOMES=(
|
||||
[nats]="/var/lib/spinifex/nats"
|
||||
[gw]="/var/lib/spinifex/awsgw"
|
||||
[daemon]="/var/lib/spinifex/spinifex"
|
||||
[storage]="/var/lib/spinifex/predastore"
|
||||
[viperblock]="/var/lib/spinifex/viperblock"
|
||||
[vpcd]="/var/lib/spinifex"
|
||||
[ui]="/var/lib/spinifex"
|
||||
)
|
||||
for svc in nats gw daemon storage viperblock vpcd ui; do
|
||||
local user="spinifex-${svc}"
|
||||
if ! id "$user" > /dev/null 2>&1; then
|
||||
$SUDO useradd --system --no-create-home \
|
||||
--home-dir "${SERVICE_HOMES[$svc]}" \
|
||||
--gid "$SPINIFEX_GROUP" \
|
||||
--shell /usr/sbin/nologin \
|
||||
"$user"
|
||||
fi
|
||||
done
|
||||
|
||||
# Allow the service user to manage OVN/OVS and tap devices
|
||||
$SUDO tee /etc/sudoers.d/spinifex-network > /dev/null << SUDOERS
|
||||
# Spinifex VPC networking: allow service user to manage tap devices, OVS, and OVN
|
||||
${SPINIFEX_USER} ALL=(root) NOPASSWD: /sbin/ip, /usr/sbin/ip
|
||||
${SPINIFEX_USER} ALL=(root) NOPASSWD: /usr/bin/ovs-vsctl, /usr/bin/ovs-appctl
|
||||
${SPINIFEX_USER} ALL=(root) NOPASSWD: /usr/bin/ovn-nbctl, /usr/bin/ovn-sbctl
|
||||
# Add invoking user to spinifex group for admin CLI access
|
||||
ADMIN_USER="${SUDO_USER:-$(whoami)}"
|
||||
if [ "$ADMIN_USER" != "root" ]; then
|
||||
$SUDO usermod -aG "$SPINIFEX_GROUP" "$ADMIN_USER"
|
||||
fi
|
||||
|
||||
# KVM access for daemon
|
||||
if getent group kvm > /dev/null 2>&1; then
|
||||
$SUDO usermod -aG kvm spinifex-daemon
|
||||
fi
|
||||
|
||||
info "Service users created (spinifex-{nats,gw,daemon,storage,viperblock,vpcd,ui})"
|
||||
}
|
||||
|
||||
# --- Install scoped sudoers rules ---
|
||||
install_sudoers() {
|
||||
$SUDO tee /etc/sudoers.d/spinifex-network > /dev/null << 'SUDOERS'
|
||||
# Spinifex daemon: tap devices, OVS bridge management, and DHCP for external IPs
|
||||
spinifex-daemon ALL=(root) NOPASSWD: /sbin/ip, /usr/sbin/ip
|
||||
spinifex-daemon ALL=(root) NOPASSWD: /usr/bin/ovs-vsctl, /usr/bin/ovs-appctl
|
||||
spinifex-daemon ALL=(root) NOPASSWD: /usr/sbin/dhcpcd
|
||||
|
||||
# Spinifex VPC daemon: OVN and OVS read/write, OVN controller status check
|
||||
spinifex-vpcd ALL=(root) NOPASSWD: /usr/bin/ovs-vsctl, /usr/bin/ovs-appctl
|
||||
spinifex-vpcd ALL=(root) NOPASSWD: /usr/bin/ovn-nbctl, /usr/bin/ovn-sbctl
|
||||
spinifex-vpcd ALL=(root) NOPASSWD: /usr/bin/systemctl is-active --quiet ovn-controller
|
||||
SUDOERS
|
||||
$SUDO chmod 0440 /etc/sudoers.d/spinifex-network
|
||||
info "Sudoers rules installed for $SPINIFEX_USER"
|
||||
$SUDO visudo -cf /etc/sudoers.d/spinifex-network || fatal "Invalid sudoers syntax in spinifex-network"
|
||||
info "Scoped sudoers rules installed for spinifex-daemon and spinifex-vpcd"
|
||||
}
|
||||
|
||||
# --- Install apt dependencies ---
|
||||
@@ -241,12 +280,14 @@ install_files() {
|
||||
create_directories() {
|
||||
info "Creating directories..."
|
||||
|
||||
# Top-level directories (root-owned, group-readable by spinifex)
|
||||
$SUDO mkdir -p /etc/spinifex
|
||||
$SUDO chmod 0700 /etc/spinifex
|
||||
$SUDO chown "$SPINIFEX_USER:$SPINIFEX_GROUP" /etc/spinifex
|
||||
$SUDO chmod 0750 /etc/spinifex
|
||||
$SUDO chown "root:$SPINIFEX_GROUP" /etc/spinifex
|
||||
|
||||
$SUDO mkdir -p /var/lib/spinifex
|
||||
$SUDO chown "$SPINIFEX_USER:$SPINIFEX_GROUP" /var/lib/spinifex
|
||||
$SUDO chmod 0750 /var/lib/spinifex
|
||||
$SUDO chown "root:$SPINIFEX_GROUP" /var/lib/spinifex
|
||||
|
||||
# Symlink so services that expect BaseDir/config/ can find /etc/spinifex/
|
||||
if [ ! -e /var/lib/spinifex/config ]; then
|
||||
@@ -259,26 +300,70 @@ create_directories() {
|
||||
fi
|
||||
|
||||
$SUDO mkdir -p /var/log/spinifex
|
||||
$SUDO chown "$SPINIFEX_USER:$SPINIFEX_GROUP" /var/log/spinifex
|
||||
$SUDO chmod 0775 /var/log/spinifex
|
||||
$SUDO chown "root:$SPINIFEX_GROUP" /var/log/spinifex
|
||||
|
||||
$SUDO mkdir -p /run/spinifex
|
||||
$SUDO chown "$SPINIFEX_USER:$SPINIFEX_GROUP" /run/spinifex
|
||||
$SUDO chmod 0775 /run/spinifex
|
||||
$SUDO chown "root:$SPINIFEX_GROUP" /run/spinifex
|
||||
|
||||
$SUDO mkdir -p /run/spinifex/nbd
|
||||
$SUDO chmod 0770 /run/spinifex/nbd
|
||||
$SUDO chown "root:$SPINIFEX_GROUP" /run/spinifex/nbd
|
||||
|
||||
# Per-service config directories
|
||||
$SUDO mkdir -p /etc/spinifex/nats
|
||||
$SUDO chown "spinifex-nats:$SPINIFEX_GROUP" /etc/spinifex/nats
|
||||
$SUDO chmod 0750 /etc/spinifex/nats
|
||||
|
||||
$SUDO mkdir -p /etc/spinifex/predastore
|
||||
$SUDO chown "spinifex-storage:$SPINIFEX_GROUP" /etc/spinifex/predastore
|
||||
$SUDO chmod 0750 /etc/spinifex/predastore
|
||||
|
||||
# Per-service data directories
|
||||
$SUDO mkdir -p /var/lib/spinifex/nats
|
||||
$SUDO chown "spinifex-nats:$SPINIFEX_GROUP" /var/lib/spinifex/nats
|
||||
$SUDO chmod 0700 /var/lib/spinifex/nats
|
||||
|
||||
$SUDO mkdir -p /var/lib/spinifex/spinifex
|
||||
$SUDO chown "spinifex-daemon:$SPINIFEX_GROUP" /var/lib/spinifex/spinifex
|
||||
$SUDO chmod 0700 /var/lib/spinifex/spinifex
|
||||
|
||||
$SUDO mkdir -p /var/lib/spinifex/predastore
|
||||
$SUDO chown "spinifex-storage:$SPINIFEX_GROUP" /var/lib/spinifex/predastore
|
||||
$SUDO chmod 0700 /var/lib/spinifex/predastore
|
||||
|
||||
$SUDO mkdir -p /var/lib/spinifex/viperblock
|
||||
$SUDO chown "$SPINIFEX_USER:$SPINIFEX_GROUP" /var/lib/spinifex/viperblock
|
||||
$SUDO chown "spinifex-viperblock:$SPINIFEX_GROUP" /var/lib/spinifex/viperblock
|
||||
$SUDO chmod 0700 /var/lib/spinifex/viperblock
|
||||
|
||||
$SUDO mkdir -p /var/lib/spinifex/vpcd
|
||||
$SUDO chown "spinifex-vpcd:$SPINIFEX_GROUP" /var/lib/spinifex/vpcd
|
||||
$SUDO chmod 0700 /var/lib/spinifex/vpcd
|
||||
|
||||
$SUDO mkdir -p /var/lib/spinifex/awsgw
|
||||
$SUDO chown "spinifex-gw:$SPINIFEX_GROUP" /var/lib/spinifex/awsgw
|
||||
$SUDO chmod 0700 /var/lib/spinifex/awsgw
|
||||
|
||||
# Symlink so awsgw's {BaseDir}/config/ paths resolve to /etc/spinifex/
|
||||
if [ ! -e /var/lib/spinifex/awsgw/config ]; then
|
||||
$SUDO ln -s /etc/spinifex /var/lib/spinifex/awsgw/config
|
||||
fi
|
||||
|
||||
# Top-level config files (root-owned, group-readable)
|
||||
# Generate environment file with install-specific values (e.g. arch-dependent paths)
|
||||
$SUDO tee /etc/spinifex/systemd.env > /dev/null << EOF
|
||||
# Generated by setup.sh — install-specific environment variables
|
||||
SPINIFEX_VIPERBLOCK_PLUGIN_PATH=${PLUGINDIR}/nbdkit-viperblock-plugin.so
|
||||
EOF
|
||||
$SUDO chown "$SPINIFEX_USER:$SPINIFEX_GROUP" /etc/spinifex/systemd.env
|
||||
$SUDO chown "spinifex-viperblock:$SPINIFEX_GROUP" /etc/spinifex/systemd.env
|
||||
$SUDO chmod 0640 /etc/spinifex/systemd.env
|
||||
info "Generated /etc/spinifex/systemd.env"
|
||||
|
||||
# Service helper scripts
|
||||
# Service helper scripts (root-owned, group-executable by all service users)
|
||||
if [ -d "$EXTRACT_DIR/scripts" ]; then
|
||||
for script in "$EXTRACT_DIR"/scripts/*.sh; do
|
||||
$SUDO install -o "$SPINIFEX_USER" -g "$SPINIFEX_GROUP" -m 0755 \
|
||||
$SUDO install -o root -g "$SPINIFEX_GROUP" -m 0755 \
|
||||
"$script" "/var/lib/spinifex/$(basename "$script")"
|
||||
info " /var/lib/spinifex/$(basename "$script")"
|
||||
done
|
||||
@@ -294,15 +379,12 @@ install_systemd() {
|
||||
fi
|
||||
|
||||
for unit in "$EXTRACT_DIR"/systemd/*; do
|
||||
# Substitute User= and Group= with the detected service user
|
||||
sed "s/^User=spinifex$/User=$SPINIFEX_USER/;s/^Group=spinifex$/Group=$SPINIFEX_GROUP/" \
|
||||
"$unit" | $SUDO tee "/etc/systemd/system/$(basename "$unit")" > /dev/null
|
||||
$SUDO chmod 0644 "/etc/systemd/system/$(basename "$unit")"
|
||||
$SUDO install -m 0644 "$unit" "/etc/systemd/system/$(basename "$unit")"
|
||||
info " /etc/systemd/system/$(basename "$unit")"
|
||||
done
|
||||
|
||||
$SUDO systemctl daemon-reload
|
||||
info "Systemd units installed (running as $SPINIFEX_USER:$SPINIFEX_GROUP)"
|
||||
info "Systemd units installed (per-service users)"
|
||||
}
|
||||
|
||||
# --- Install logrotate ---
|
||||
@@ -343,7 +425,7 @@ print_summary() {
|
||||
echo ""
|
||||
echo " Version: $INSTALLED_VERSION"
|
||||
echo " Architecture: $ARCH"
|
||||
echo " Service user: $SPINIFEX_USER"
|
||||
echo " Service users: spinifex-{nats,gw,daemon,storage,viperblock,vpcd,ui}"
|
||||
echo " Binary: /usr/local/bin/spx"
|
||||
echo " Config: /etc/spinifex/"
|
||||
echo " Data: /var/lib/spinifex/"
|
||||
@@ -384,7 +466,8 @@ main() {
|
||||
handle_upgrade
|
||||
install_apt_deps
|
||||
install_aws_cli
|
||||
detect_service_user
|
||||
create_service_users
|
||||
install_sudoers
|
||||
download_spinifex
|
||||
install_files
|
||||
create_directories
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "WARNING: start-dev.sh is deprecated for local development." >&2
|
||||
echo " Use 'make deploy' for daily iteration or 'scripts/dev-install.sh' for first-time setup." >&2
|
||||
echo " start-dev.sh is retained only for pseudo-multinode CI (e2e_pseudo_multi)." >&2
|
||||
echo "" >&2
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
MULGA_ROOT="$(cd "$PROJECT_ROOT/.." && pwd)"
|
||||
@@ -33,6 +38,8 @@ echo "Using configuration directory: $CONFIG_DIR"
|
||||
LOGS_DIR="$DATA_DIR/logs"
|
||||
WAL_DIR="$DATA_DIR/spinifex"
|
||||
|
||||
echo "⚠️ Dev mode: all services run as $(whoami) without privilege separation"
|
||||
echo ""
|
||||
echo "🚀 Starting Spinifex development environment..."
|
||||
echo "Project root: $PROJECT_ROOT"
|
||||
echo "Data directory: $DATA_DIR"
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
sudo apt update
|
||||
sudo apt install -y make gcc git
|
||||
|
||||
wget https://go.dev/dl/go1.26.1.linux-amd64.tar.gz
|
||||
sudo tar -C /usr/local -xzf go1.26.1.linux-amd64.tar.gz
|
||||
wget https://go.dev/dl/go1.26.2.linux-amd64.tar.gz
|
||||
sudo tar -C /usr/local -xzf go1.26.2.linux-amd64.tar.gz
|
||||
|
||||
echo "export PATH=$PATH:/usr/local/go/bin" >> $HOME/.bashrc
|
||||
source $HOME/.bashrc
|
||||
|
||||
+96
-6
@@ -40,6 +40,7 @@ type ConfigSettings struct {
|
||||
Region string
|
||||
NatsToken string
|
||||
DataDir string
|
||||
LogDir string
|
||||
ConfigDir string
|
||||
|
||||
// Add more fields as needed
|
||||
@@ -212,7 +213,9 @@ func CreateServiceDirectories(spxRoot string) {
|
||||
filepath.Join(spxRoot, "nats"),
|
||||
filepath.Join(spxRoot, "predastore"),
|
||||
filepath.Join(spxRoot, "viperblock"),
|
||||
filepath.Join(spxRoot, "vpcd"),
|
||||
filepath.Join(spxRoot, "spinifex"),
|
||||
filepath.Join(spxRoot, "awsgw"),
|
||||
}
|
||||
|
||||
fmt.Println("\n📁 Creating directory structure...")
|
||||
@@ -241,18 +244,30 @@ func FileExists(path string) bool {
|
||||
func ChownRecursive(path, username string) {
|
||||
u, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
slog.Warn("ChownRecursive: user lookup failed, skipping", "user", username, "path", path, "err", err)
|
||||
return
|
||||
}
|
||||
uid, err := strconv.Atoi(u.Uid)
|
||||
if err != nil {
|
||||
slog.Warn("ChownRecursive: invalid UID, skipping", "user", username, "uid", u.Uid, "err", err)
|
||||
return
|
||||
}
|
||||
gid, err := strconv.Atoi(u.Gid)
|
||||
if err != nil {
|
||||
slog.Warn("ChownRecursive: invalid GID, skipping", "user", username, "gid", u.Gid, "err", err)
|
||||
return
|
||||
}
|
||||
uid, _ := strconv.Atoi(u.Uid)
|
||||
gid, _ := strconv.Atoi(u.Gid)
|
||||
|
||||
// Use os.Root to scope filesystem operations and avoid symlink TOCTOU races.
|
||||
// Falls back to direct chown if os.Root is not available.
|
||||
root, rootErr := os.OpenRoot(path)
|
||||
if rootErr != nil {
|
||||
// Fallback: direct chown on the top-level path only
|
||||
if chownErr := os.Chown(path, uid, gid); chownErr != nil { // #nosec G122
|
||||
slog.Debug("chown failed", "path", path, "err", chownErr)
|
||||
// Fallback: direct chown on the top-level path only — subdirectory contents
|
||||
// will retain their original ownership.
|
||||
slog.Warn("ChownRecursive: OpenRoot not available, only top-level directory ownership changed",
|
||||
"path", path, "err", rootErr)
|
||||
if chownErr := os.Lchown(path, uid, gid); chownErr != nil {
|
||||
slog.Warn("chown failed", "path", path, "err", chownErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -271,6 +286,81 @@ func ChownRecursive(path, username string) {
|
||||
})
|
||||
}
|
||||
|
||||
// SetServiceOwnership sets per-service ownership on data/config directories
|
||||
// and shared config files to root:spinifex with correct modes.
|
||||
// Keep in sync with setup.sh create_directories() and plan doc section 2.
|
||||
func SetServiceOwnership() {
|
||||
grp, err := user.LookupGroup("spinifex")
|
||||
if err != nil {
|
||||
slog.Error("SetServiceOwnership: spinifex group not found, skipping all ownership changes", "err", err)
|
||||
fmt.Fprintln(os.Stderr, "WARNING: spinifex group not found — service ownership not set. Run setup.sh first or create the group manually.")
|
||||
return
|
||||
}
|
||||
gid, err := strconv.Atoi(grp.Gid)
|
||||
if err != nil {
|
||||
slog.Error("SetServiceOwnership: invalid spinifex group GID", "gid", grp.Gid, "err", err)
|
||||
fmt.Fprintln(os.Stderr, "WARNING: invalid spinifex group GID — service ownership not set.")
|
||||
return
|
||||
}
|
||||
|
||||
// Per-service directory trees
|
||||
for path, u := range map[string]string{
|
||||
"/etc/spinifex/nats": "spinifex-nats",
|
||||
"/var/lib/spinifex/nats": "spinifex-nats",
|
||||
"/etc/spinifex/predastore": "spinifex-storage",
|
||||
"/var/lib/spinifex/predastore": "spinifex-storage",
|
||||
"/var/lib/spinifex/spinifex": "spinifex-daemon",
|
||||
"/var/lib/spinifex/viperblock": "spinifex-viperblock",
|
||||
"/var/lib/spinifex/vpcd": "spinifex-vpcd",
|
||||
"/var/lib/spinifex/awsgw": "spinifex-gw",
|
||||
} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
ChownRecursive(path, u)
|
||||
}
|
||||
|
||||
// Shared data directories — root:spinifex 0770 so daemon + admin CLI can write
|
||||
for _, dir := range []string{
|
||||
"/var/lib/spinifex/images",
|
||||
"/var/lib/spinifex/amis",
|
||||
"/var/lib/spinifex/volumes",
|
||||
"/var/lib/spinifex/state",
|
||||
"/run/spinifex/nbd",
|
||||
} {
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := os.Lchown(dir, 0, gid); err != nil {
|
||||
slog.Warn("SetServiceOwnership: chown failed", "path", dir, "err", err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0770); err != nil { //nolint:gosec // directories need group-write for daemon + admin CLI
|
||||
slog.Warn("SetServiceOwnership: chmod failed", "path", dir, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shared config files — root:spinifex, ca.key stays root:root 0600
|
||||
// bootstrap.json lives in the awsgw data dir (not /etc/spinifex),
|
||||
// so /etc/spinifex stays at 0750 (no group-write needed).
|
||||
for path, mode := range map[string]os.FileMode{
|
||||
"/etc/spinifex/spinifex.toml": 0640,
|
||||
"/etc/spinifex/master.key": 0640,
|
||||
"/etc/spinifex/server.pem": 0644,
|
||||
"/etc/spinifex/server.key": 0640,
|
||||
"/etc/spinifex/ca.pem": 0644,
|
||||
} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := os.Lchown(path, 0, gid); err != nil {
|
||||
slog.Warn("SetServiceOwnership: chown failed", "path", path, "err", err)
|
||||
}
|
||||
if err := os.Chmod(path, mode); err != nil {
|
||||
slog.Warn("SetServiceOwnership: chmod failed", "path", path, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateAWSINIFile updates or creates an AWS INI file section with given key-value pairs
|
||||
func UpdateAWSINIFile(path, section string, values map[string]string) error {
|
||||
var cfg *ini.File
|
||||
@@ -753,7 +843,7 @@ func SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP string)
|
||||
|
||||
if err := UpdateAWSINIFile(configPath, configSection, map[string]string{
|
||||
"region": region,
|
||||
"endpoint_url": fmt.Sprintf("https://%s:9999", endpointHost),
|
||||
"endpoint_url": "https://" + net.JoinHostPort(endpointHost, "9999"),
|
||||
"ca_bundle": certPath,
|
||||
"output": "json",
|
||||
}); err != nil {
|
||||
|
||||
+42
-42
@@ -55,80 +55,80 @@ type BootstrapConfig struct {
|
||||
// Config holds all configuration for the application
|
||||
type Config struct {
|
||||
// Node config
|
||||
Node string `mapstructure:"node"`
|
||||
Host string `mapstructure:"host"` // Unique hostname or IP of this node
|
||||
Region string `mapstructure:"region"`
|
||||
AZ string `mapstructure:"az"`
|
||||
DataDir string `mapstructure:"data_dir"`
|
||||
Services []string `mapstructure:"services"` // Which services this node runs locally
|
||||
Node string `json:"Node" mapstructure:"node"`
|
||||
Host string `json:"Host" mapstructure:"host"` // Unique hostname or IP of this node
|
||||
Region string `json:"Region" mapstructure:"region"`
|
||||
AZ string `json:"AZ" mapstructure:"az"`
|
||||
DataDir string `json:"DataDir" mapstructure:"data_dir"`
|
||||
Services []string `json:"Services" mapstructure:"services"` // Which services this node runs locally
|
||||
|
||||
Daemon DaemonConfig `mapstructure:"daemon"`
|
||||
NATS NATSConfig `mapstructure:"nats"`
|
||||
Predastore PredastoreConfig `mapstructure:"predastore"`
|
||||
Viperblock ViperblockConfig `mapstructure:"viperblock"`
|
||||
AWSGW AWSGWConfig `mapstructure:"awsgw"`
|
||||
VPCD VPCDConfig `mapstructure:"vpcd"`
|
||||
Daemon DaemonConfig `json:"Daemon" mapstructure:"daemon"`
|
||||
NATS NATSConfig `json:"NATS" mapstructure:"nats"`
|
||||
Predastore PredastoreConfig `json:"Predastore" mapstructure:"predastore"`
|
||||
Viperblock ViperblockConfig `json:"Viperblock" mapstructure:"viperblock"`
|
||||
AWSGW AWSGWConfig `json:"AWSGW" mapstructure:"awsgw"`
|
||||
VPCD VPCDConfig `json:"VPCD" mapstructure:"vpcd"`
|
||||
|
||||
BaseDir string `mapstructure:"base_dir"`
|
||||
WalDir string `mapstructure:"wal_dir"`
|
||||
BaseDir string `json:"BaseDir" mapstructure:"base_dir"`
|
||||
WalDir string `json:"WalDir" mapstructure:"wal_dir"`
|
||||
}
|
||||
|
||||
type AWSGWConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
TLSKey string `mapstructure:"tlskey"`
|
||||
TLSCert string `mapstructure:"tlscert"`
|
||||
Config string `mapstructure:"config"`
|
||||
Host string `json:"Host" mapstructure:"host"`
|
||||
TLSKey string `json:"TLSKey" mapstructure:"tlskey"`
|
||||
TLSCert string `json:"TLSCert" mapstructure:"tlscert"`
|
||||
Config string `json:"Config" mapstructure:"config"`
|
||||
|
||||
Debug bool `mapstructure:"debug"`
|
||||
ExpectedNodes int `mapstructure:"expected_nodes"` // TODO: Replace with root cluster config
|
||||
Debug bool `json:"Debug" mapstructure:"debug"`
|
||||
ExpectedNodes int `json:"ExpectedNodes" mapstructure:"expected_nodes"` // TODO: Replace with root cluster config
|
||||
}
|
||||
|
||||
type ViperblockConfig struct {
|
||||
ShardWAL *bool `mapstructure:"shardwal"` // Enable sharded WAL (default false when nil)
|
||||
ShardWAL *bool `json:"ShardWAL" mapstructure:"shardwal"` // Enable sharded WAL (default false when nil)
|
||||
}
|
||||
|
||||
// VPCDConfig holds the VPC daemon (vpcd) configuration.
|
||||
type VPCDConfig struct {
|
||||
OVNNBAddr string `mapstructure:"ovn_nb_addr"` // OVN Northbound DB address (e.g., "tcp:127.0.0.1:6641")
|
||||
OVNSBAddr string `mapstructure:"ovn_sb_addr"` // OVN Southbound DB address (e.g., "tcp:127.0.0.1:6642")
|
||||
ExternalInterface string `mapstructure:"external_interface"` // WAN NIC name (e.g., "eth1", "enp0s3") — the physical NIC on the WAN bridge
|
||||
WanBridge string `mapstructure:"wan_bridge"` // OVS bridge for WAN traffic (default "br-wan", maps to OVN "external" network)
|
||||
BridgeMode string `mapstructure:"bridge_mode"` // "direct" or "macvlan" (auto-detected if empty)
|
||||
OVNNBAddr string `json:"OVNNBAddr" mapstructure:"ovn_nb_addr"` // OVN Northbound DB address (e.g., "tcp:127.0.0.1:6641")
|
||||
OVNSBAddr string `json:"OVNSBAddr" mapstructure:"ovn_sb_addr"` // OVN Southbound DB address (e.g., "tcp:127.0.0.1:6642")
|
||||
ExternalInterface string `json:"ExternalInterface" mapstructure:"external_interface"` // WAN NIC name (e.g., "eth1", "enp0s3") — the physical NIC on the WAN bridge
|
||||
WanBridge string `json:"WanBridge" mapstructure:"wan_bridge"` // OVS bridge for WAN traffic (default "br-wan", maps to OVN "external" network)
|
||||
BridgeMode string `json:"BridgeMode" mapstructure:"bridge_mode"` // "direct" or "macvlan" (auto-detected if empty)
|
||||
}
|
||||
|
||||
type PredastoreConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Bucket string `mapstructure:"bucket"`
|
||||
Region string `mapstructure:"region"`
|
||||
AccessKey string `mapstructure:"accesskey"`
|
||||
SecretKey string `mapstructure:"secretkey"`
|
||||
BaseDir string `mapstructure:"base_dir"`
|
||||
NodeID int `mapstructure:"node_id"`
|
||||
Host string `json:"Host" mapstructure:"host"`
|
||||
Bucket string `json:"Bucket" mapstructure:"bucket"`
|
||||
Region string `json:"Region" mapstructure:"region"`
|
||||
AccessKey string `json:"AccessKey" mapstructure:"accesskey"`
|
||||
SecretKey string `json:"SecretKey" mapstructure:"secretkey"`
|
||||
BaseDir string `json:"BaseDir" mapstructure:"base_dir"`
|
||||
NodeID int `json:"NodeID" mapstructure:"node_id"`
|
||||
}
|
||||
|
||||
// DaemonConfig holds the daemon configuration
|
||||
type DaemonConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
TLSKey string `mapstructure:"tlskey"`
|
||||
TLSCert string `mapstructure:"tlscert"`
|
||||
DevNetworking bool `mapstructure:"dev_networking"` // VPC instances get both TAP + hostfwd for SSH dev access
|
||||
Host string `json:"Host" mapstructure:"host"`
|
||||
TLSKey string `json:"TLSKey" mapstructure:"tlskey"`
|
||||
TLSCert string `json:"TLSCert" mapstructure:"tlscert"`
|
||||
DevNetworking bool `json:"DevNetworking" mapstructure:"dev_networking"` // VPC instances get both TAP + hostfwd for SSH dev access
|
||||
}
|
||||
|
||||
// NATSConfig holds the NATS configuration
|
||||
type NATSConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
ACL NATSACL `mapstructure:"acl"`
|
||||
Sub NATSSub `mapstructure:"sub"`
|
||||
Host string `json:"Host" mapstructure:"host"`
|
||||
ACL NATSACL `json:"ACL" mapstructure:"acl"`
|
||||
Sub NATSSub `json:"Sub" mapstructure:"sub"`
|
||||
}
|
||||
|
||||
// NATSACL holds the NATS ACL configuration
|
||||
type NATSACL struct {
|
||||
Token string `mapstructure:"token"`
|
||||
Token string `json:"Token" mapstructure:"token"`
|
||||
}
|
||||
|
||||
// NATSSub holds the NATS subscription configuration
|
||||
type NATSSub struct {
|
||||
Subject string `mapstructure:"subject"`
|
||||
Subject string `json:"Subject" mapstructure:"subject"`
|
||||
}
|
||||
|
||||
// NodeBaseDir returns the BaseDir for the current node, or "" if the config
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -202,7 +203,7 @@ func (d *Daemon) queryNATSRole() string {
|
||||
if !d.config.HasService("nats") {
|
||||
return ""
|
||||
}
|
||||
url := fmt.Sprintf("http://%s:%d/varz", d.daemonIP(), natsMonitorPort)
|
||||
url := "http://" + net.JoinHostPort(d.daemonIP(), strconv.Itoa(natsMonitorPort)) + "/varz"
|
||||
return fetchNATSRole(url, roleHTTPClient)
|
||||
}
|
||||
|
||||
@@ -212,7 +213,7 @@ func (d *Daemon) queryPredastoreRole() string {
|
||||
if !d.config.HasService("predastore") {
|
||||
return ""
|
||||
}
|
||||
url := fmt.Sprintf("https://%s:%d/status", d.daemonIP(), predastoreDBPort)
|
||||
url := "https://" + net.JoinHostPort(d.daemonIP(), strconv.Itoa(predastoreDBPort)) + "/status"
|
||||
return fetchPredastoreRole(url, roleTLSHTTPClient)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,12 +31,6 @@ const (
|
||||
func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip OPTIONS requests (CORS preflight)
|
||||
if r.Method == http.MethodOptions {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorMissingAuthenticationToken)
|
||||
|
||||
@@ -333,20 +333,6 @@ func TestSigV4Auth_ValidSignature(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSigV4Auth_OptionsSkipsAuth(t *testing.T) {
|
||||
handler := setupTestApp(testAccessKey, testSecretKey)
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/", nil)
|
||||
req.Host = "localhost:9999"
|
||||
// No Authorization header
|
||||
|
||||
resp := doRequest(handler, req)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200 for OPTIONS, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSigV4Auth_ValidSignatureWithBody(t *testing.T) {
|
||||
handler := setupTestApp(testAccessKey, testSecretKey)
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ func ValidateRunInstancesInput(input *ec2.RunInstancesInput) (err error) {
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
if input.KeyName == nil || *input.KeyName == "" {
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(*input.ImageId, "ami-") {
|
||||
return errors.New(awserrors.ErrorInvalidAMIIDMalformed)
|
||||
}
|
||||
|
||||
@@ -572,6 +572,7 @@ func TestRunInstances_SingleInstanceDistributes(t *testing.T) {
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-test"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
KeyName: aws.String("test-key"),
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
}
|
||||
@@ -1073,6 +1074,7 @@ func TestRunInstances_ClusterPlacementGroupRouting(t *testing.T) {
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-test"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
KeyName: aws.String("test-key"),
|
||||
MinCount: aws.Int64(2),
|
||||
MaxCount: aws.Int64(2),
|
||||
Placement: &ec2.Placement{
|
||||
@@ -1123,6 +1125,7 @@ func TestRunInstances_MultiInstanceUsesDistribution(t *testing.T) {
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-test"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
KeyName: aws.String("test-key"),
|
||||
MinCount: aws.Int64(2),
|
||||
MaxCount: aws.Int64(2),
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -98,9 +97,6 @@ func (gw *GatewayConfig) SetupRoutes() http.Handler {
|
||||
r.Use(slogRequestLogger)
|
||||
}
|
||||
|
||||
// CORS middleware for browser requests
|
||||
r.Use(corsMiddleware)
|
||||
|
||||
// AWS SigV4 authentication middleware
|
||||
r.Use(gw.SigV4AuthMiddleware())
|
||||
|
||||
@@ -110,66 +106,6 @@ func (gw *GatewayConfig) SetupRoutes() http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
// corsAllowedOrigins builds the set of allowed CORS origins from localhost and
|
||||
// all local non-loopback IPs on the spinifex-ui port (default 3000). This allows
|
||||
// the UI to be accessed from any local address, not just localhost.
|
||||
func corsAllowedOrigins() map[string]struct{} {
|
||||
origins := map[string]struct{}{
|
||||
"https://localhost:3000": {},
|
||||
}
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return origins
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if ip == nil || ip.IsLoopback() || ip.To4() == nil {
|
||||
continue
|
||||
}
|
||||
origins[fmt.Sprintf("https://%s:3000", ip.String())] = struct{}{}
|
||||
}
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
// corsMiddleware adds CORS headers for browser requests from the spinifex-ui.
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
allowed := corsAllowedOrigins()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
origin = "https://localhost:3000"
|
||||
}
|
||||
if _, ok := allowed[origin]; ok {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
} else {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "https://localhost:3000")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,HEAD,OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Note, custom endpoints can be configured via ENV vars to the AWS SDK/CLI tool, with individual endpoints depending the service
|
||||
// AWS_ENDPOINT_URL_EC2=https://localhost:9999/ aws --no-verify-ssl ec2 describe-instances
|
||||
// aws --endpoint-url https://localhost:9999/ --no-verify-ssl eks list-clusters
|
||||
|
||||
@@ -882,78 +882,6 @@ func TestIsNATSTransient(t *testing.T) {
|
||||
assert.False(t, isNATSTransient(errors.New("some other error")))
|
||||
}
|
||||
|
||||
func TestCorsMiddleware_GETRequest(t *testing.T) {
|
||||
nextCalled := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := corsMiddleware(next)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
assert.True(t, nextCalled)
|
||||
assert.Equal(t, "https://localhost:3000", w.Header().Get("Access-Control-Allow-Origin"))
|
||||
assert.Equal(t, "GET,POST,PUT,DELETE,HEAD,OPTIONS", w.Header().Get("Access-Control-Allow-Methods"))
|
||||
assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Headers"))
|
||||
assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials"))
|
||||
}
|
||||
|
||||
func TestCorsMiddleware_OPTIONSRequest(t *testing.T) {
|
||||
nextCalled := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
nextCalled = true
|
||||
})
|
||||
|
||||
handler := corsMiddleware(next)
|
||||
req := httptest.NewRequest(http.MethodOptions, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
assert.False(t, nextCalled, "next handler should not be called for OPTIONS")
|
||||
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||
assert.Equal(t, "https://localhost:3000", w.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
|
||||
func TestCorsMiddleware_LocalIPOrigin(t *testing.T) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := corsMiddleware(next)
|
||||
|
||||
// Request from a local IP should be reflected
|
||||
allowed := corsAllowedOrigins()
|
||||
for origin := range allowed {
|
||||
if origin == "https://localhost:3000" {
|
||||
continue
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Origin", origin)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
assert.Equal(t, origin, w.Header().Get("Access-Control-Allow-Origin"))
|
||||
return // test one IP is enough
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorsMiddleware_UnknownOrigin(t *testing.T) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := corsMiddleware(next)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Origin", "https://evil.example.com")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
// Unknown origins fall back to localhost
|
||||
assert.Equal(t, "https://localhost:3000", w.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
|
||||
func TestSlogRequestLogger_CallsNext(t *testing.T) {
|
||||
nextCalled := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -86,8 +86,8 @@ func matchWildcard(pattern, value string) bool {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(pattern, "*") {
|
||||
prefix := pattern[:len(pattern)-1]
|
||||
return strings.HasPrefix(value, prefix)
|
||||
prefix := strings.ToLower(pattern[:len(pattern)-1])
|
||||
return strings.HasPrefix(strings.ToLower(value), prefix)
|
||||
}
|
||||
return strings.EqualFold(pattern, value)
|
||||
}
|
||||
|
||||
@@ -233,11 +233,13 @@ func TestMatchWildcard(t *testing.T) {
|
||||
{"ec2:*", "ec2:RunInstances", true},
|
||||
{"ec2:*", "ec2:DescribeInstances", true},
|
||||
{"ec2:*", "s3:GetObject", false},
|
||||
{"EC2:*", "ec2:RunInstances", true},
|
||||
|
||||
// Prefix wildcard
|
||||
{"s3:Get*", "s3:GetObject", true},
|
||||
{"s3:Get*", "s3:GetBucketPolicy", true},
|
||||
{"s3:Get*", "s3:PutObject", false},
|
||||
{"S3:get*", "s3:GetObject", true},
|
||||
|
||||
// Exact match
|
||||
{"ec2:RunInstances", "ec2:RunInstances", true},
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -117,7 +119,7 @@ func queryDBNodeStatus(ctx context.Context, out *DBNodeStatus, host string, port
|
||||
}
|
||||
|
||||
// Query /health
|
||||
healthURL := fmt.Sprintf("https://%s:%d/health", queryHost, port)
|
||||
healthURL := "https://" + net.JoinHostPort(queryHost, strconv.Itoa(port)) + "/health"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -131,7 +133,7 @@ func queryDBNodeStatus(ctx context.Context, out *DBNodeStatus, host string, port
|
||||
out.Healthy = resp.StatusCode == http.StatusOK
|
||||
|
||||
// Query /status
|
||||
statusURL := fmt.Sprintf("https://%s:%d/status", queryHost, port)
|
||||
statusURL := "https://" + net.JoinHostPort(queryHost, strconv.Itoa(port)) + "/status"
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -107,8 +107,10 @@ func launchService(config *config.ClusterConfig) error {
|
||||
return fmt.Errorf("initialize IAM service: %w", err)
|
||||
}
|
||||
|
||||
// First boot: consume bootstrap.json → seed IAM users into NATS KV → delete file
|
||||
bootstrapPath := filepath.Join(nodeConfig.BaseDir, "config", "bootstrap.json")
|
||||
// First boot: consume bootstrap.json → seed IAM users into NATS KV → delete file.
|
||||
// Check data directory first (production: /var/lib/spinifex/awsgw/), then
|
||||
// awsgw subdir (dev: ~/spinifex/awsgw/), then legacy config dir.
|
||||
bootstrapPath := findBootstrapFile(nodeConfig.BaseDir)
|
||||
data, err := handlers_iam.LoadBootstrapData(bootstrapPath)
|
||||
switch {
|
||||
case err == nil:
|
||||
@@ -118,8 +120,9 @@ func launchService(config *config.ClusterConfig) error {
|
||||
}
|
||||
if err := os.Remove(bootstrapPath); err != nil {
|
||||
slog.Warn("Failed to delete bootstrap file", "path", bootstrapPath, "err", err)
|
||||
} else {
|
||||
slog.Info("Bootstrap complete, bootstrap.json deleted")
|
||||
}
|
||||
slog.Info("Bootstrap complete, bootstrap.json deleted")
|
||||
case os.IsNotExist(err):
|
||||
// No bootstrap file — normal after first boot
|
||||
default:
|
||||
@@ -223,3 +226,21 @@ func initIAMService(natsConn *nats.Conn, masterKey []byte, clusterSize int) (*ha
|
||||
retryDelay = min(retryDelay*2, 10*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// findBootstrapFile returns the path to bootstrap.json, checking the data
|
||||
// directory first (production), then the awsgw subdir (dev mode), then the
|
||||
// legacy config dir. Returns the first path that exists, or the primary
|
||||
// location if none exist (so the caller gets a clean "not found" error).
|
||||
func findBootstrapFile(baseDir string) string {
|
||||
candidates := []string{
|
||||
filepath.Join(baseDir, "bootstrap.json"),
|
||||
filepath.Join(baseDir, "awsgw", "bootstrap.json"),
|
||||
filepath.Join(baseDir, "config", "bootstrap.json"),
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -252,7 +253,7 @@ func waitForServer(timeout time.Duration) bool {
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := client.Get(fmt.Sprintf("https://%s:%d/", testHost, testPort))
|
||||
resp, err := client.Get("https://" + net.JoinHostPort(testHost, strconv.Itoa(testPort)) + "/")
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
// Give a bit more time for config to load
|
||||
@@ -273,7 +274,7 @@ func createS3Client(accessKey, secretKey string) *s3.S3 {
|
||||
|
||||
sess := session.Must(session.NewSession(&aws.Config{
|
||||
Region: aws.String(testRegion),
|
||||
Endpoint: aws.String(fmt.Sprintf("https://%s:%d", testHost, testPort)),
|
||||
Endpoint: aws.String("https://" + net.JoinHostPort(testHost, strconv.Itoa(testPort))),
|
||||
Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
DisableSSL: aws.Bool(false),
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{It as e,Lt as t,Vt as n,pt as r}from"./awsClient.js";var i=class extends n.classBuilder().ep(e).m(function(e,n,r,i){return[t(r,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DescribeInstances`,{}).n(`EC2Client`,`DescribeInstancesCommand`).sc(r).build(){};export{i as t};
|
||||
import{It as e,Rt as t,pt as n,zt as r}from"./awsClient.js";var i=class extends t.classBuilder().ep(e).m(function(e,t,n,i){return[r(n,e.getEndpointParameterInstructions())]}).s(`AmazonEC2`,`DescribeInstances`,{}).n(`EC2Client`,`DescribeInstancesCommand`).sc(n).build(){};export{i as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{A as e,D as t,E as n,O as r,T as i,k as a,w as o}from"./SignatureV4.js";import{F as s,I as c,P as l}from"./ec2.js";var u=/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/,d=e().min(1,`Key name is required`).max(255,`Key name must be 255 characters or less`).regex(/^[\w\s._\-:/()#,@[\]+=&;{}!$*]+$/,`Key name contains invalid characters`),f=a({imageId:e(`Please select an Image`),instanceType:e(`Please select an instance type`),keyName:e(`Please select a key pair`),subnetId:e().optional(),placementGroupName:e().optional(),count:t(`Instance count must be a whole number`).min(1,`Instance count must be at least 1`)}),p=a({keyName:d}),m=a({keyName:d,publicKeyMaterial:e().min(1,`Public key is required`).refine(e=>e.trim().length>0,`Public key cannot be empty`)}),h=a({size:r().int(`Size must be a whole number`).min(1,`Size must be at least 1 GiB`).max(16384,`Size must be at most 16384 GiB`),availabilityZone:e().min(1,`Availability zone is required`)}),g=a({size:r().int(`Size must be a whole number`).min(1,`Size must be at least 1 GiB`)}),_=a({volumeId:e().min(1,`Volume is required`),description:e().optional()});a({sourceSnapshotId:e().min(1,`Source snapshot is required`),sourceRegion:e().min(1,`Source region is required`),description:e().optional()}),a({volumeId:e().min(1,`Volume is required`),instanceId:e().min(1,`Instance is required`),device:e().optional()}),a({volumeId:e().min(1,`Volume is required`),instanceId:e().optional(),force:n().optional()}),a({instanceType:e().min(1,`Instance type is required`)}),a({name:e().min(1,`Name is required`),description:e().optional()});var v=a({vpcId:e().min(1,`VPC is required`),cidrBlock:e().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.1.0/24)`),availabilityZone:e().optional()});a({cidrBlock:e().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.0.0/16)`),name:e().optional()});var y=a({key:e().min(1,`Key is required`),value:e()}),b=a({mode:o([`vpc-only`,`vpc-and-more`]),namePrefix:e().optional(),autoGenerateNames:n(),cidrBlock:e().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.0.0/16)`).refine(e=>c(e),`CIDR has invalid octets or prefix length (must be /16 to /28)`),tenancy:o([`default`,`dedicated`]),publicSubnetCount:r().int().min(0).max(1),privateSubnetCount:r().int().min(0).max(2),publicSubnetCidrs:i(e()),privateSubnetCidrs:i(e()),tags:i(y)}).superRefine((e,t)=>{if(e.mode!==`vpc-and-more`)return;let n=[];for(let[r,i]of e.publicSubnetCidrs.entries())c(i,16,28)?(l(e.cidrBlock,i)||t.addIssue({code:`custom`,message:`Subnet CIDR must be within the VPC CIDR range`,path:[`publicSubnetCidrs`,r]}),n.push({cidr:i,field:`publicSubnetCidrs`,index:r})):t.addIssue({code:`custom`,message:`Invalid subnet CIDR format or prefix length`,path:[`publicSubnetCidrs`,r]});for(let[r,i]of e.privateSubnetCidrs.entries())c(i,16,28)?(l(e.cidrBlock,i)||t.addIssue({code:`custom`,message:`Subnet CIDR must be within the VPC CIDR range`,path:[`privateSubnetCidrs`,r]}),n.push({cidr:i,field:`privateSubnetCidrs`,index:r})):t.addIssue({code:`custom`,message:`Invalid subnet CIDR format or prefix length`,path:[`privateSubnetCidrs`,r]});for(let e=0;e<n.length;e++)for(let r=e+1;r<n.length;r++){let i=n[e],a=n[r];i&&a&&s(i.cidr,a.cidr)&&t.addIssue({code:`custom`,message:`Subnet CIDRs must not overlap`,path:[a.field,a.index]})}}),x=a({groupName:e().min(1,`Group name is required`).max(255,`Group name must be 255 characters or less`),strategy:e().min(1,`Strategy is required`)}),S=a({groupName:e().min(1,`Group name is required`).max(255,`Group name must be 255 characters or less`),description:e().min(1,`Description is required`).max(255,`Description must be 255 characters or less`),vpcId:e().min(1,`VPC is required`)}),C=a({ipProtocol:e().min(1,`Protocol is required`),fromPort:r().int().min(-1).max(65535),toPort:r().int().min(-1).max(65535),cidrIp:e().min(1,`CIDR is required`).regex(u,`Must be a valid CIDR block (e.g. 0.0.0.0/0)`)});export{_ as a,b as c,C as d,S as i,m as l,p as n,v as o,x as r,h as s,f as t,g as u};
|
||||
import{A as e,D as t,E as n,O as r,T as i,k as a,w as o}from"./SignatureV4.js";import{F as s,I as c,P as l}from"./ec2.js";var u=/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/,d=e().min(1,`Key name is required`).max(255,`Key name must be 255 characters or less`).regex(/^[\w\s._\-:/()#,@[\]+=&;{}!$*]+$/,`Key name contains invalid characters`),f=a({imageId:e(`Please select an Image`),instanceType:e(`Please select an instance type`),keyName:e(`Please select a key pair`).min(1,`Key pair is required`),subnetId:e().optional(),placementGroupName:e().optional(),count:t(`Instance count must be a whole number`).min(1,`Instance count must be at least 1`)}),p=a({keyName:d}),m=a({keyName:d,publicKeyMaterial:e().min(1,`Public key is required`).refine(e=>e.trim().length>0,`Public key cannot be empty`)}),h=a({size:r().int(`Size must be a whole number`).min(1,`Size must be at least 1 GiB`).max(16384,`Size must be at most 16384 GiB`),availabilityZone:e().min(1,`Availability zone is required`)}),g=a({size:r().int(`Size must be a whole number`).min(1,`Size must be at least 1 GiB`)}),_=a({volumeId:e().min(1,`Volume is required`),description:e().optional()});a({sourceSnapshotId:e().min(1,`Source snapshot is required`),sourceRegion:e().min(1,`Source region is required`),description:e().optional()}),a({volumeId:e().min(1,`Volume is required`),instanceId:e().min(1,`Instance is required`),device:e().optional()}),a({volumeId:e().min(1,`Volume is required`),instanceId:e().optional(),force:n().optional()}),a({instanceType:e().min(1,`Instance type is required`)}),a({name:e().min(1,`Name is required`),description:e().optional()});var v=a({vpcId:e().min(1,`VPC is required`),cidrBlock:e().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.1.0/24)`),availabilityZone:e().optional()});a({cidrBlock:e().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.0.0/16)`),name:e().optional()});var y=a({key:e().min(1,`Key is required`),value:e()}),b=a({mode:o([`vpc-only`,`vpc-and-more`]),namePrefix:e().optional(),autoGenerateNames:n(),cidrBlock:e().min(1,`CIDR block is required`).regex(u,`Must be a valid CIDR block (e.g. 10.0.0.0/16)`).refine(e=>c(e),`CIDR has invalid octets or prefix length (must be /16 to /28)`),tenancy:o([`default`,`dedicated`]),publicSubnetCount:r().int().min(0).max(1),privateSubnetCount:r().int().min(0).max(2),publicSubnetCidrs:i(e()),privateSubnetCidrs:i(e()),tags:i(y)}).superRefine((e,t)=>{if(e.mode!==`vpc-and-more`)return;let n=[];for(let[r,i]of e.publicSubnetCidrs.entries())c(i,16,28)?(l(e.cidrBlock,i)||t.addIssue({code:`custom`,message:`Subnet CIDR must be within the VPC CIDR range`,path:[`publicSubnetCidrs`,r]}),n.push({cidr:i,field:`publicSubnetCidrs`,index:r})):t.addIssue({code:`custom`,message:`Invalid subnet CIDR format or prefix length`,path:[`publicSubnetCidrs`,r]});for(let[r,i]of e.privateSubnetCidrs.entries())c(i,16,28)?(l(e.cidrBlock,i)||t.addIssue({code:`custom`,message:`Subnet CIDR must be within the VPC CIDR range`,path:[`privateSubnetCidrs`,r]}),n.push({cidr:i,field:`privateSubnetCidrs`,index:r})):t.addIssue({code:`custom`,message:`Invalid subnet CIDR format or prefix length`,path:[`privateSubnetCidrs`,r]});for(let e=0;e<n.length;e++)for(let r=e+1;r<n.length;r++){let i=n[e],a=n[r];i&&a&&s(i.cidr,a.cidr)&&t.addIssue({code:`custom`,message:`Subnet CIDRs must not overlap`,path:[a.field,a.index]})}}),x=a({groupName:e().min(1,`Group name is required`).max(255,`Group name must be 255 characters or less`),strategy:e().min(1,`Strategy is required`)}),S=a({groupName:e().min(1,`Group name is required`).max(255,`Group name must be 255 characters or less`),description:e().min(1,`Description is required`).max(255,`Description must be 255 characters or less`),vpcId:e().min(1,`VPC is required`)}),C=a({ipProtocol:e().min(1,`Protocol is required`),fromPort:r().int().min(-1).max(65535),toPort:r().int().min(-1).max(65535),cidrIp:e().min(1,`CIDR is required`).regex(u,`Must be a valid CIDR block (e.g. 0.0.0.0/0)`)});export{_ as a,b as c,C as d,S as i,m as l,p as n,v as o,x as r,h as s,f as t,g as u};
|
||||
@@ -1 +1 @@
|
||||
import{t as e}from"./compiler-runtime.js";import{n as t}from"./QueryClientProvider.js";import{n}from"./error-banner.js";import{A as r,C as i,D as a,E as o,Lt as s,O as c,R as l,T as u,Vt as d,k as f,r as p,w as m,z as h}from"./awsClient.js";var g=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`AttachUserPolicy`,{}).n(`IAMClient`,`AttachUserPolicyCommand`).sc(i).build(){},_=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreateAccessKey`,{}).n(`IAMClient`,`CreateAccessKeyCommand`).sc(m).build(){},v=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreatePolicy`,{}).n(`IAMClient`,`CreatePolicyCommand`).sc(u).build(){},y=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreateUser`,{}).n(`IAMClient`,`CreateUserCommand`).sc(o).build(){},b=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeleteAccessKey`,{}).n(`IAMClient`,`DeleteAccessKeyCommand`).sc(a).build(){},x=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeletePolicy`,{}).n(`IAMClient`,`DeletePolicyCommand`).sc(c).build(){},S=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeleteUser`,{}).n(`IAMClient`,`DeleteUserCommand`).sc(f).build(){},C=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DetachUserPolicy`,{}).n(`IAMClient`,`DetachUserPolicyCommand`).sc(r).build(){},w=class extends d.classBuilder().ep(h).m(function(e,t,n,r){return[s(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`UpdateAccessKey`,{}).n(`IAMClient`,`UpdateAccessKeyCommand`).sc(l).build(){},T=e();function E(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:D,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`users`]})}},e[0]=r,e[1]=i),n(i)}function D(e){let t=new y({UserName:e.userName,Path:e.path||void 0});return p().send(t)}function O(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:k,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`users`]})}},e[0]=r,e[1]=i),n(i)}function k(e){let t=new S({UserName:e});return p().send(t)}function A(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:j,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},e[0]=r,e[1]=i),n(i)}function j(e){let t=new _({UserName:e});return p().send(t)}function M(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:N,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},e[0]=r,e[1]=i),n(i)}function N(e){let{userName:t,accessKeyId:n}=e,r=new b({UserName:t,AccessKeyId:n});return p().send(r)}function P(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:F,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},e[0]=r,e[1]=i),n(i)}function F(e){let{userName:t,accessKeyId:n,status:r}=e,i=new w({UserName:t,AccessKeyId:n,Status:r});return p().send(i)}function I(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:L,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`policies`]})}},e[0]=r,e[1]=i),n(i)}function L(e){let t=new v({PolicyName:e.policyName,Description:e.description||void 0,PolicyDocument:e.policyDocument});return p().send(t)}function R(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:z,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`policies`]})}},e[0]=r,e[1]=i),n(i)}function z(e){let t=new x({PolicyArn:e});return p().send(t)}function B(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:V,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`attached-user-policies`]})}},e[0]=r,e[1]=i),n(i)}function V(e){let{userName:t,policyArn:n}=e,r=new g({UserName:t,PolicyArn:n});return p().send(r)}function H(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:U,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`attached-user-policies`]})}},e[0]=r,e[1]=i),n(i)}function U(e){let{userName:t,policyArn:n}=e,r=new C({UserName:t,PolicyArn:n});return p().send(r)}export{M as a,H as c,E as i,P as l,A as n,R as o,I as r,O as s,B as t};
|
||||
import{t as e}from"./compiler-runtime.js";import{n as t}from"./QueryClientProvider.js";import{n}from"./error-banner.js";import{A as r,C as i,D as a,E as o,O as s,R as c,Rt as l,T as u,k as d,r as f,w as p,z as m,zt as h}from"./awsClient.js";var g=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`AttachUserPolicy`,{}).n(`IAMClient`,`AttachUserPolicyCommand`).sc(i).build(){},_=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreateAccessKey`,{}).n(`IAMClient`,`CreateAccessKeyCommand`).sc(p).build(){},v=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreatePolicy`,{}).n(`IAMClient`,`CreatePolicyCommand`).sc(u).build(){},y=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`CreateUser`,{}).n(`IAMClient`,`CreateUserCommand`).sc(o).build(){},b=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeleteAccessKey`,{}).n(`IAMClient`,`DeleteAccessKeyCommand`).sc(a).build(){},x=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeletePolicy`,{}).n(`IAMClient`,`DeletePolicyCommand`).sc(s).build(){},S=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DeleteUser`,{}).n(`IAMClient`,`DeleteUserCommand`).sc(d).build(){},C=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`DetachUserPolicy`,{}).n(`IAMClient`,`DetachUserPolicyCommand`).sc(r).build(){},w=class extends l.classBuilder().ep(m).m(function(e,t,n,r){return[h(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`UpdateAccessKey`,{}).n(`IAMClient`,`UpdateAccessKeyCommand`).sc(c).build(){},T=e();function E(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:D,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`users`]})}},e[0]=r,e[1]=i),n(i)}function D(e){let t=new y({UserName:e.userName,Path:e.path||void 0});return f().send(t)}function O(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:k,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`users`]})}},e[0]=r,e[1]=i),n(i)}function k(e){let t=new S({UserName:e});return f().send(t)}function A(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:j,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},e[0]=r,e[1]=i),n(i)}function j(e){let t=new _({UserName:e});return f().send(t)}function M(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:N,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},e[0]=r,e[1]=i),n(i)}function N(e){let{userName:t,accessKeyId:n}=e,r=new b({UserName:t,AccessKeyId:n});return f().send(r)}function P(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:F,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`access-keys`]})}},e[0]=r,e[1]=i),n(i)}function F(e){let{userName:t,accessKeyId:n,status:r}=e,i=new w({UserName:t,AccessKeyId:n,Status:r});return f().send(i)}function I(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:L,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`policies`]})}},e[0]=r,e[1]=i),n(i)}function L(e){let t=new v({PolicyName:e.policyName,Description:e.description||void 0,PolicyDocument:e.policyDocument});return f().send(t)}function R(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:z,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`policies`]})}},e[0]=r,e[1]=i),n(i)}function z(e){let t=new x({PolicyArn:e});return f().send(t)}function B(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:V,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`attached-user-policies`]})}},e[0]=r,e[1]=i),n(i)}function V(e){let{userName:t,policyArn:n}=e,r=new g({UserName:t,PolicyArn:n});return f().send(r)}function H(){let e=(0,T.c)(2),r=t(),i;return e[0]===r?i=e[1]:(i={mutationFn:U,onSuccess:()=>{r.invalidateQueries({queryKey:[`iam`,`attached-user-policies`]})}},e[0]=r,e[1]=i),n(i)}function U(e){let{userName:t,policyArn:n}=e,r=new C({UserName:t,PolicyArn:n});return f().send(r)}export{M as a,H as c,E as i,P as l,A as n,R as o,I as r,O as s,B as t};
|
||||
@@ -1 +1 @@
|
||||
import{t as e}from"./queryOptions.js";import{F as t,I as n,L as r,Lt as i,M as a,N as o,P as s,Vt as c,j as l,r as u,z as d}from"./awsClient.js";var f=class extends c.classBuilder().ep(d).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`GetPolicy`,{}).n(`IAMClient`,`GetPolicyCommand`).sc(l).build(){},p=class extends c.classBuilder().ep(d).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`GetPolicyVersion`,{}).n(`IAMClient`,`GetPolicyVersionCommand`).sc(a).build(){},m=class extends c.classBuilder().ep(d).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`GetUser`,{}).n(`IAMClient`,`GetUserCommand`).sc(o).build(){},h=class extends c.classBuilder().ep(d).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`ListAccessKeys`,{}).n(`IAMClient`,`ListAccessKeysCommand`).sc(s).build(){},g=class extends c.classBuilder().ep(d).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`ListAttachedUserPolicies`,{}).n(`IAMClient`,`ListAttachedUserPoliciesCommand`).sc(t).build(){},_=class extends c.classBuilder().ep(d).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`ListPolicies`,{}).n(`IAMClient`,`ListPoliciesCommand`).sc(n).build(){},v=class extends c.classBuilder().ep(d).m(function(e,t,n,r){return[i(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`ListUsers`,{}).n(`IAMClient`,`ListUsersCommand`).sc(r).build(){},y=e({queryKey:[`iam`,`users`],queryFn:()=>{let e=new v({});return u().send(e)},staleTime:3e5}),b=t=>e({queryKey:[`iam`,`users`,t],queryFn:()=>{let e=new m({UserName:t});return u().send(e)},staleTime:3e5}),x=t=>e({queryKey:[`iam`,`access-keys`,t],queryFn:()=>{let e=new h({UserName:t});return u().send(e)},staleTime:3e5}),S=e({queryKey:[`iam`,`policies`],queryFn:()=>{let e=new _({Scope:`Local`});return u().send(e)},staleTime:3e5}),C=t=>e({queryKey:[`iam`,`policies`,t],queryFn:()=>{let e=new f({PolicyArn:t});return u().send(e)},staleTime:3e5}),w=(t,n)=>e({queryKey:[`iam`,`policy-versions`,t,n],queryFn:()=>{let e=new p({PolicyArn:t,VersionId:n});return u().send(e)},staleTime:3e5}),T=t=>e({queryKey:[`iam`,`attached-user-policies`,t],queryFn:()=>{let e=new g({UserName:t});return u().send(e)},staleTime:3e5});export{w as a,C as i,T as n,b as o,S as r,y as s,x as t};
|
||||
import{t as e}from"./queryOptions.js";import{F as t,I as n,L as r,M as i,N as a,P as o,Rt as s,j as c,r as l,z as u,zt as d}from"./awsClient.js";var f=class extends s.classBuilder().ep(u).m(function(e,t,n,r){return[d(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`GetPolicy`,{}).n(`IAMClient`,`GetPolicyCommand`).sc(c).build(){},p=class extends s.classBuilder().ep(u).m(function(e,t,n,r){return[d(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`GetPolicyVersion`,{}).n(`IAMClient`,`GetPolicyVersionCommand`).sc(i).build(){},m=class extends s.classBuilder().ep(u).m(function(e,t,n,r){return[d(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`GetUser`,{}).n(`IAMClient`,`GetUserCommand`).sc(a).build(){},h=class extends s.classBuilder().ep(u).m(function(e,t,n,r){return[d(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`ListAccessKeys`,{}).n(`IAMClient`,`ListAccessKeysCommand`).sc(o).build(){},g=class extends s.classBuilder().ep(u).m(function(e,t,n,r){return[d(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`ListAttachedUserPolicies`,{}).n(`IAMClient`,`ListAttachedUserPoliciesCommand`).sc(t).build(){},_=class extends s.classBuilder().ep(u).m(function(e,t,n,r){return[d(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`ListPolicies`,{}).n(`IAMClient`,`ListPoliciesCommand`).sc(n).build(){},v=class extends s.classBuilder().ep(u).m(function(e,t,n,r){return[d(n,e.getEndpointParameterInstructions())]}).s(`AWSIdentityManagementV20100508`,`ListUsers`,{}).n(`IAMClient`,`ListUsersCommand`).sc(r).build(){},y=e({queryKey:[`iam`,`users`],queryFn:()=>{let e=new v({});return l().send(e)},staleTime:3e5}),b=t=>e({queryKey:[`iam`,`users`,t],queryFn:()=>{let e=new m({UserName:t});return l().send(e)},staleTime:3e5}),x=t=>e({queryKey:[`iam`,`access-keys`,t],queryFn:()=>{let e=new h({UserName:t});return l().send(e)},staleTime:3e5}),S=e({queryKey:[`iam`,`policies`],queryFn:()=>{let e=new _({Scope:`Local`});return l().send(e)},staleTime:3e5}),C=t=>e({queryKey:[`iam`,`policies`,t],queryFn:()=>{let e=new f({PolicyArn:t});return l().send(e)},staleTime:3e5}),w=(t,n)=>e({queryKey:[`iam`,`policy-versions`,t,n],queryFn:()=>{let e=new p({PolicyArn:t,VersionId:n});return l().send(e)},staleTime:3e5}),T=t=>e({queryKey:[`iam`,`attached-user-policies`,t],queryFn:()=>{let e=new g({UserName:t});return l().send(e)},staleTime:3e5});export{w as a,C as i,T as n,b as o,S as r,y as s,x as t};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{a as e,n as t,r as n}from"./compiler-runtime.js";import{r,t as i}from"./useRouter.js";import{L as a,M as o,S as s,h as c,m as l,t as u,u as d}from"./useStore.js";import{n as f,r as p,t as m}from"./matchContext.js";import{n as h}from"./redirect.js";import{t as g}from"./link.js";import{t as _}from"./useNavigate.js";var v=class{get to(){return this._to}get id(){return this._id}get path(){return this._path}get fullPath(){return this._fullPath}constructor(e){if(this.init=e=>{this.originalIndex=e.originalIndex;let t=this.options,n=!t?.path&&!t?.id;this.parentRoute=this.options.getParentRoute?.(),n?this._path=p:this.parentRoute||s();let r=n?p:t?.path;r&&r!==`/`&&(r=l(r));let i=t?.id||r,a=n?p:d([this.parentRoute.id===`__root__`?``:this.parentRoute.id,i]);r===`__root__`&&(r=`/`),a!==`__root__`&&(a=d([`/`,a]));let o=a===`__root__`?`/`:d([this.parentRoute.fullPath,r]);this._path=r,this._id=a,this._fullPath=o,this._to=c(o)},this.addChildren=e=>this._addFileChildren(e),this._addFileChildren=e=>(Array.isArray(e)&&(this.children=e),typeof e==`object`&&e&&(this.children=Object.values(e)),this),this._addFileTypes=()=>this,this.updateLoader=e=>(Object.assign(this.options,e),this),this.update=e=>(Object.assign(this.options,e),this),this.lazy=e=>(this.lazyFn=e,this),this.redirect=e=>h({from:this.fullPath,...e}),this.options=e||{},this.isRoot=!e?.getParentRoute,e?.id&&e?.path)throw Error(`Route cannot have both an 'id' and a 'path' option.`)}},y=class extends v{constructor(e){super(e)}},b=e(n(),1),x={state:void 0,get:()=>void 0,subscribe:()=>()=>{}};function S(e){let t=i(),n=b.useContext(e.from?m:f),r=e.from??n,o=r?e.from?t.stores.getMatchStoreByRouteId(r):t.stores.activeMatchStoresById.get(r):void 0,c=b.useRef(void 0);return u(o??x,n=>{if((e.shouldThrow??!0)&&!n&&s(),n===void 0)return;let r=e.select?e.select(n):n;if(e.structuralSharing??t.options.defaultStructuralSharing){let e=a(c.current,r);return c.current=e,e}return r})}function C(e){return S({from:e.from,strict:e.strict,structuralSharing:e.structuralSharing,select:t=>e.select?e.select(t.loaderData):t.loaderData})}function w(e){let{select:t,...n}=e;return S({...n,select:e=>t?t(e.loaderDeps):e.loaderDeps})}function T(e){return S({from:e.from,shouldThrow:e.shouldThrow,structuralSharing:e.structuralSharing,strict:e.strict,select:t=>{let n=e.strict===!1?t.params:t._strictParams;return e.select?e.select(n):n}})}function E(e){return S({from:e.from,strict:e.strict,shouldThrow:e.shouldThrow,structuralSharing:e.structuralSharing,select:t=>e.select?e.select(t.search):t.search})}function D(e){return S({...e,select:t=>e.select?e.select(t.context):t.context})}var O=t(),k=class extends v{constructor(e){super(e),this.useMatch=e=>S({select:e?.select,from:this.id,structuralSharing:e?.structuralSharing}),this.useRouteContext=e=>D({...e,from:this.id}),this.useSearch=e=>E({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useParams=e=>T({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useLoaderDeps=e=>w({...e,from:this.id}),this.useLoaderData=e=>C({...e,from:this.id}),this.useNavigate=()=>_({from:this.fullPath}),this.Link=b.forwardRef((e,t)=>(0,O.jsx)(g,{ref:t,from:this.fullPath,...e}))}};function A(e){return new k(e)}function j(){return e=>N(e)}var M=class extends y{constructor(e){super(e),this.useMatch=e=>S({select:e?.select,from:this.id,structuralSharing:e?.structuralSharing}),this.useRouteContext=e=>D({...e,from:this.id}),this.useSearch=e=>E({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useParams=e=>T({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useLoaderDeps=e=>w({...e,from:this.id}),this.useLoaderData=e=>C({...e,from:this.id}),this.useNavigate=()=>_({from:this.fullPath}),this.Link=b.forwardRef((e,t)=>(0,O.jsx)(g,{ref:t,from:this.fullPath,...e}))}};function N(e){return new M(e)}function P(e){return typeof e==`object`?new F(e,{silent:!0}).createRoute(e):new F(e,{silent:!0}).createRoute}var F=class{constructor(e,t){this.path=e,this.createRoute=e=>{let t=A(e);return t.isRoot=!1,t},this.silent=t?.silent}};function I(e,t){let n,i,a,s,c=()=>(n||=e().then(e=>{n=void 0,i=e[t??`default`]}).catch(e=>{if(a=e,o(a)&&a instanceof Error&&typeof window<`u`&&typeof sessionStorage<`u`){let e=`tanstack_router_reload:${a.message}`;sessionStorage.getItem(e)||(sessionStorage.setItem(e,`1`),s=!0)}}),n),l=function(e){if(s)throw window.location.reload(),new Promise(()=>{});if(a)throw a;if(!i)if(r)r(c());else throw c();return b.createElement(i,e)};return l.preload=c,l}export{P as n,j as r,I as t};
|
||||
import{a as e,n as t,r as n}from"./compiler-runtime.js";import{B as r,P as i,_ as a,f as o,g as s,i as c,t as l,w as u,z as d}from"./useStore.js";import{n as f,r as p,t as m}from"./matchContext.js";import{n as h}from"./redirect.js";import{t as g}from"./link.js";import{t as _}from"./useNavigate.js";var v=class{get to(){return this._to}get id(){return this._id}get path(){return this._path}get fullPath(){return this._fullPath}constructor(e){if(this.init=e=>{this.originalIndex=e.originalIndex;let t=this.options,n=!t?.path&&!t?.id;this.parentRoute=this.options.getParentRoute?.(),n?this._path=p:this.parentRoute||u();let r=n?p:t?.path;r&&r!==`/`&&(r=s(r));let i=t?.id||r,c=n?p:o([this.parentRoute.id===`__root__`?``:this.parentRoute.id,i]);r===`__root__`&&(r=`/`),c!==`__root__`&&(c=o([`/`,c]));let l=c===`__root__`?`/`:o([this.parentRoute.fullPath,r]);this._path=r,this._id=c,this._fullPath=l,this._to=a(l)},this.addChildren=e=>this._addFileChildren(e),this._addFileChildren=e=>(Array.isArray(e)&&(this.children=e),typeof e==`object`&&e&&(this.children=Object.values(e)),this),this._addFileTypes=()=>this,this.updateLoader=e=>(Object.assign(this.options,e),this),this.update=e=>(Object.assign(this.options,e),this),this.lazy=e=>(this.lazyFn=e,this),this.redirect=e=>h({from:this.fullPath,...e}),this.options=e||{},this.isRoot=!e?.getParentRoute,e?.id&&e?.path)throw Error(`Route cannot have both an 'id' and a 'path' option.`)}},y=class extends v{constructor(e){super(e)}},b=e(n(),1),x={state:void 0,get:()=>void 0,subscribe:()=>()=>{}};function S(e){let t=c(),n=b.useContext(e.from?m:f),r=e.from??n,i=r?e.from?t.stores.getMatchStoreByRouteId(r):t.stores.activeMatchStoresById.get(r):void 0,a=b.useRef(void 0);return l(i??x,n=>{if((e.shouldThrow??!0)&&!n&&u(),n===void 0)return;let r=e.select?e.select(n):n;if(e.structuralSharing??t.options.defaultStructuralSharing){let e=d(a.current,r);return a.current=e,e}return r})}function C(e){return S({from:e.from,strict:e.strict,structuralSharing:e.structuralSharing,select:t=>e.select?e.select(t.loaderData):t.loaderData})}function w(e){let{select:t,...n}=e;return S({...n,select:e=>t?t(e.loaderDeps):e.loaderDeps})}function T(e){return S({from:e.from,shouldThrow:e.shouldThrow,structuralSharing:e.structuralSharing,strict:e.strict,select:t=>{let n=e.strict===!1?t.params:t._strictParams;return e.select?e.select(n):n}})}function E(e){return S({from:e.from,strict:e.strict,shouldThrow:e.shouldThrow,structuralSharing:e.structuralSharing,select:t=>e.select?e.select(t.search):t.search})}function D(e){return S({...e,select:t=>e.select?e.select(t.context):t.context})}var O=t(),k=class extends v{constructor(e){super(e),this.useMatch=e=>S({select:e?.select,from:this.id,structuralSharing:e?.structuralSharing}),this.useRouteContext=e=>D({...e,from:this.id}),this.useSearch=e=>E({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useParams=e=>T({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useLoaderDeps=e=>w({...e,from:this.id}),this.useLoaderData=e=>C({...e,from:this.id}),this.useNavigate=()=>_({from:this.fullPath}),this.Link=b.forwardRef((e,t)=>(0,O.jsx)(g,{ref:t,from:this.fullPath,...e}))}};function A(e){return new k(e)}function j(){return e=>N(e)}var M=class extends y{constructor(e){super(e),this.useMatch=e=>S({select:e?.select,from:this.id,structuralSharing:e?.structuralSharing}),this.useRouteContext=e=>D({...e,from:this.id}),this.useSearch=e=>E({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useParams=e=>T({select:e?.select,structuralSharing:e?.structuralSharing,from:this.id}),this.useLoaderDeps=e=>w({...e,from:this.id}),this.useLoaderData=e=>C({...e,from:this.id}),this.useNavigate=()=>_({from:this.fullPath}),this.Link=b.forwardRef((e,t)=>(0,O.jsx)(g,{ref:t,from:this.fullPath,...e}))}};function N(e){return new M(e)}function P(e){return typeof e==`object`?new F(e,{silent:!0}).createRoute(e):new F(e,{silent:!0}).createRoute}var F=class{constructor(e,t){this.path=e,this.createRoute=e=>{let t=A(e);return t.isRoot=!1,t},this.silent=t?.silent}};function I(e,t){let n,a,o,s,c=()=>(n||=e().then(e=>{n=void 0,a=e[t??`default`]}).catch(e=>{if(o=e,i(o)&&o instanceof Error&&typeof window<`u`&&typeof sessionStorage<`u`){let e=`tanstack_router_reload:${o.message}`;sessionStorage.getItem(e)||(sessionStorage.setItem(e,`1`),s=!0)}}),n),l=function(e){if(s)throw window.location.reload(),new Promise(()=>{});if(o)throw o;if(!a)if(r)r(c());else throw c();return b.createElement(a,e)};return l.preload=c,l}export{P as n,j as r,I as t};
|
||||
@@ -1 +1 @@
|
||||
import{a as e,n as t,r as n}from"./compiler-runtime.js";import{a as r,i as ee,t as i}from"./useRouter.js";import{A as a,D as te,a as ne,c as o,d as s,j as c,t as re}from"./useStore.js";import{Xt as l}from"./awsClient.js";var ie=`Error preloading route! ☝️`,u=e(n(),1);t();var d=e(l(),1);function f(e,t){let n=i(),l=ee(t),{activeProps:f,inactiveProps:g,activeOptions:_,to:v,preload:pe,preloadDelay:me,hashScrollIntoView:he,replace:ge,startTransition:_e,resetScroll:y,viewTransition:b,children:x,target:S,disabled:C,style:w,className:T,onClick:E,onBlur:D,onFocus:O,onMouseEnter:k,onMouseLeave:A,onTouchStart:j,ignoreBlocker:M,params:ve,search:ye,hash:be,state:xe,mask:Se,reloadDocument:Ce,unsafeRelative:we,from:Te,_fromLocation:Ee,...N}=e,P=ne(),F=u.useMemo(()=>e,[n,e.from,e._fromLocation,e.hash,e.to,e.search,e.params,e.state,e.mask,e.unsafeRelative]),I=re(n.stores.location,e=>e,(e,t)=>e.href===t.href),L=u.useMemo(()=>{let e={_fromLocation:I,...F};return n.buildLocation(e)},[n,I,F]),R=L.maskedLocation?L.maskedLocation.publicHref:L.publicHref,z=L.maskedLocation?L.maskedLocation.external:L.external,B=u.useMemo(()=>ue(R,z,n.history,C),[C,z,R,n.history]),V=u.useMemo(()=>{if(B?.external)return c(B.href,n.protocolAllowlist)?void 0:B.href;if(!de(v)&&!(typeof v!=`string`||v.indexOf(`:`)===-1))try{return new URL(v),c(v,n.protocolAllowlist)?void 0:v}catch{}},[v,B,n.protocolAllowlist]),H=u.useMemo(()=>{if(V)return!1;if(_?.exact){if(!o(I.pathname,L.pathname,n.basepath))return!1}else{let e=s(I.pathname,n.basepath),t=s(L.pathname,n.basepath);if(!(e.startsWith(t)&&(e.length===t.length||e[t.length]===`/`)))return!1}return(_?.includeSearch??!0)&&!te(I.search,L.search,{partial:!_?.exact,ignoreUndefined:!_?.explicitUndefined})?!1:_?.includeHash?P&&I.hash===L.hash:!0},[_?.exact,_?.explicitUndefined,_?.includeHash,_?.includeSearch,I,V,P,L.hash,L.pathname,L.search,n.basepath]),U=H?a(f,{})??ae:p,W=H?p:a(g,{})??p,G=[T,U.className,W.className].filter(Boolean).join(` `),K=(w||U.style||W.style)&&{...w,...U.style,...W.style},[De,q]=u.useState(!1),J=u.useRef(!1),Y=e.reloadDocument||V?!1:pe??n.options.defaultPreload,X=me??n.options.defaultPreloadDelay??0,Z=u.useCallback(()=>{n.preloadRoute({...F,_builtLocation:L}).catch(e=>{console.warn(e),console.warn(ie)})},[n,F,L]);r(l,u.useCallback(e=>{e?.isIntersecting&&Z()},[Z]),le,{disabled:!!C||Y!==`viewport`}),u.useEffect(()=>{J.current||!C&&Y===`render`&&(Z(),J.current=!0)},[C,Z,Y]);let Oe=e=>{let t=e.currentTarget.getAttribute(`target`),r=S===void 0?t:S;if(!C&&!fe(e)&&!e.defaultPrevented&&(!r||r===`_self`)&&e.button===0){e.preventDefault(),(0,d.flushSync)(()=>{q(!0)});let t=n.subscribe(`onResolved`,()=>{t(),q(!1)});n.navigate({...F,replace:ge,resetScroll:y,hashScrollIntoView:he,startTransition:_e,viewTransition:b,ignoreBlocker:M})}};if(V)return{...N,ref:l,href:V,...x&&{children:x},...S&&{target:S},...C&&{disabled:C},...w&&{style:w},...T&&{className:T},...E&&{onClick:E},...D&&{onBlur:D},...O&&{onFocus:O},...k&&{onMouseEnter:k},...A&&{onMouseLeave:A},...j&&{onTouchStart:j}};let Q=e=>{if(C||Y!==`intent`)return;if(!X){Z();return}let t=e.currentTarget;if(m.has(t))return;let n=setTimeout(()=>{m.delete(t),Z()},X);m.set(t,n)},ke=e=>{C||Y!==`intent`||Z()},$=e=>{if(C||!Y||!X)return;let t=e.currentTarget,n=m.get(t);n&&(clearTimeout(n),m.delete(t))};return{...N,...U,...W,href:B?.href,ref:l,onClick:h([E,Oe]),onBlur:h([D,$]),onFocus:h([O,Q]),onMouseEnter:h([k,Q]),onMouseLeave:h([A,$]),onTouchStart:h([j,ke]),disabled:!!C,target:S,...K&&{style:K},...G&&{className:G},...C&&oe,...H&&se,...P&&De&&ce}}var p={},ae={className:`active`},oe={role:`link`,"aria-disabled":!0},se={"data-status":`active`,"aria-current":`page`},ce={"data-transitioning":`transitioning`},m=new WeakMap,le={rootMargin:`100px`},h=e=>t=>{for(let n of e)if(n){if(t.defaultPrevented)return;n(t)}};function ue(e,t,n,r){if(!r)return t?{href:e,external:!0}:{href:n.createHref(e)||`/`,external:!1}}function de(e){if(typeof e!=`string`)return!1;let t=e.charCodeAt(0);return t===47?e.charCodeAt(1)!==47:t===46}var g=u.forwardRef((e,t)=>{let{_asChild:n,...r}=e,{type:ee,...i}=f(r,t),a=typeof r.children==`function`?r.children({isActive:i[`data-status`]===`active`}):r.children;if(!n){let{disabled:e,...t}=i;return u.createElement(`a`,t,a)}return u.createElement(n,i,a)});function fe(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}export{g as t};
|
||||
import{a as e,n as t,r as n}from"./compiler-runtime.js";import{H as r,M as i,N as a,V as o,i as ee,k as te,p as s,s as ne,t as re,u as ie}from"./useStore.js";import{Xt as c}from"./awsClient.js";var ae=`Error preloading route! ☝️`,l=e(n(),1);t();var oe=e(c(),1);function u(e,t){let n=ee(),c=o(t),{activeProps:u,inactiveProps:h,activeOptions:g,to:_,preload:me,preloadDelay:he,hashScrollIntoView:ge,replace:_e,startTransition:v,resetScroll:y,viewTransition:b,children:x,target:S,disabled:C,style:w,className:T,onClick:E,onBlur:D,onFocus:O,onMouseEnter:k,onMouseLeave:A,onTouchStart:j,ignoreBlocker:M,params:ve,search:ye,hash:be,state:xe,mask:Se,reloadDocument:Ce,unsafeRelative:we,from:Te,_fromLocation:Ee,...N}=e,P=ne(),F=l.useMemo(()=>e,[n,e.from,e._fromLocation,e.hash,e.to,e.search,e.params,e.state,e.mask,e.unsafeRelative]),I=re(n.stores.location,e=>e,(e,t)=>e.href===t.href),L=l.useMemo(()=>{let e={_fromLocation:I,...F};return n.buildLocation(e)},[n,I,F]),R=L.maskedLocation?L.maskedLocation.publicHref:L.publicHref,z=L.maskedLocation?L.maskedLocation.external:L.external,B=l.useMemo(()=>de(R,z,n.history,C),[C,z,R,n.history]),V=l.useMemo(()=>{if(B?.external)return a(B.href,n.protocolAllowlist)?void 0:B.href;if(!fe(_)&&!(typeof _!=`string`||_.indexOf(`:`)===-1))try{return new URL(_),a(_,n.protocolAllowlist)?void 0:_}catch{}},[_,B,n.protocolAllowlist]),H=l.useMemo(()=>{if(V)return!1;if(g?.exact){if(!ie(I.pathname,L.pathname,n.basepath))return!1}else{let e=s(I.pathname,n.basepath),t=s(L.pathname,n.basepath);if(!(e.startsWith(t)&&(e.length===t.length||e[t.length]===`/`)))return!1}return(g?.includeSearch??!0)&&!te(I.search,L.search,{partial:!g?.exact,ignoreUndefined:!g?.explicitUndefined})?!1:g?.includeHash?P&&I.hash===L.hash:!0},[g?.exact,g?.explicitUndefined,g?.includeHash,g?.includeSearch,I,V,P,L.hash,L.pathname,L.search,n.basepath]),U=H?i(u,{})??se:d,W=H?d:i(h,{})??d,G=[T,U.className,W.className].filter(Boolean).join(` `),K=(w||U.style||W.style)&&{...w,...U.style,...W.style},[De,q]=l.useState(!1),J=l.useRef(!1),Y=e.reloadDocument||V?!1:me??n.options.defaultPreload,X=he??n.options.defaultPreloadDelay??0,Z=l.useCallback(()=>{n.preloadRoute({...F,_builtLocation:L}).catch(e=>{console.warn(e),console.warn(ae)})},[n,F,L]);r(c,l.useCallback(e=>{e?.isIntersecting&&Z()},[Z]),p,{disabled:!!C||Y!==`viewport`}),l.useEffect(()=>{J.current||!C&&Y===`render`&&(Z(),J.current=!0)},[C,Z,Y]);let Oe=e=>{let t=e.currentTarget.getAttribute(`target`),r=S===void 0?t:S;if(!C&&!pe(e)&&!e.defaultPrevented&&(!r||r===`_self`)&&e.button===0){e.preventDefault(),(0,oe.flushSync)(()=>{q(!0)});let t=n.subscribe(`onResolved`,()=>{t(),q(!1)});n.navigate({...F,replace:_e,resetScroll:y,hashScrollIntoView:ge,startTransition:v,viewTransition:b,ignoreBlocker:M})}};if(V)return{...N,ref:c,href:V,...x&&{children:x},...S&&{target:S},...C&&{disabled:C},...w&&{style:w},...T&&{className:T},...E&&{onClick:E},...D&&{onBlur:D},...O&&{onFocus:O},...k&&{onMouseEnter:k},...A&&{onMouseLeave:A},...j&&{onTouchStart:j}};let Q=e=>{if(C||Y!==`intent`)return;if(!X){Z();return}let t=e.currentTarget;if(f.has(t))return;let n=setTimeout(()=>{f.delete(t),Z()},X);f.set(t,n)},ke=e=>{C||Y!==`intent`||Z()},$=e=>{if(C||!Y||!X)return;let t=e.currentTarget,n=f.get(t);n&&(clearTimeout(n),f.delete(t))};return{...N,...U,...W,href:B?.href,ref:c,onClick:m([E,Oe]),onBlur:m([D,$]),onFocus:m([O,Q]),onMouseEnter:m([k,Q]),onMouseLeave:m([A,$]),onTouchStart:m([j,ke]),disabled:!!C,target:S,...K&&{style:K},...G&&{className:G},...C&&ce,...H&&le,...P&&De&&ue}}var d={},se={className:`active`},ce={role:`link`,"aria-disabled":!0},le={"data-status":`active`,"aria-current":`page`},ue={"data-transitioning":`transitioning`},f=new WeakMap,p={rootMargin:`100px`},m=e=>t=>{for(let n of e)if(n){if(t.defaultPrevented)return;n(t)}};function de(e,t,n,r){if(!r)return t?{href:e,external:!0}:{href:n.createHref(e)||`/`,external:!1}}function fe(e){if(typeof e!=`string`)return!1;let t=e.charCodeAt(0);return t===47?e.charCodeAt(1)!==47:t===46}var h=l.forwardRef((e,t)=>{let{_asChild:n,...r}=e,{type:i,...a}=u(r,t),o=typeof r.children==`function`?r.children({isActive:a[`data-status`]===`active`}):r.children;if(!n){let{disabled:e,...t}=a;return l.createElement(`a`,t,o)}return l.createElement(n,a,o)});function pe(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}export{h as t};
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{t as e}from"./queryOptions.js";import{Lt as t,Vt as n,_ as r,d as i,f as a,g as o,i as s}from"./awsClient.js";var c=class extends n.classBuilder().ep(o).m(function(e,n,i,a){return[t(i,e.getEndpointParameterInstructions()),r(i)]}).s(`AmazonS3`,`ListBuckets`,{}).n(`S3Client`,`ListBucketsCommand`).sc(i).build(){},l=class extends n.classBuilder().ep({...o,Bucket:{type:`contextParams`,name:`Bucket`},Prefix:{type:`contextParams`,name:`Prefix`}}).m(function(e,n,i,a){return[t(i,e.getEndpointParameterInstructions()),r(i)]}).s(`AmazonS3`,`ListObjectsV2`,{}).n(`S3Client`,`ListObjectsV2Command`).sc(a).build(){},u=e({queryKey:[`s3`,`buckets`],queryFn:()=>{let e=new c({});return s().send(e)}}),d=(t,n)=>e({queryKey:[`s3`,`buckets`,t,`objects`,n??``],queryFn:()=>{let e=new l({Bucket:t,Prefix:n,Delimiter:`/`});return s().send(e)}});export{u as n,d as t};
|
||||
import{t as e}from"./queryOptions.js";import{Rt as t,_ as n,d as r,f as i,g as a,i as o,zt as s}from"./awsClient.js";var c=class extends t.classBuilder().ep(a).m(function(e,t,r,i){return[s(r,e.getEndpointParameterInstructions()),n(r)]}).s(`AmazonS3`,`ListBuckets`,{}).n(`S3Client`,`ListBucketsCommand`).sc(r).build(){},l=class extends t.classBuilder().ep({...a,Bucket:{type:`contextParams`,name:`Bucket`},Prefix:{type:`contextParams`,name:`Prefix`}}).m(function(e,t,r,i){return[s(r,e.getEndpointParameterInstructions()),n(r)]}).s(`AmazonS3`,`ListObjectsV2`,{}).n(`S3Client`,`ListObjectsV2Command`).sc(i).build(){},u=e({queryKey:[`s3`,`buckets`],queryFn:()=>{let e=new c({});return o().send(e)}}),d=(t,n)=>e({queryKey:[`s3`,`buckets`,t,`objects`,n??``],queryFn:()=>{let e=new l({Bucket:t,Prefix:n,Delimiter:`/`});return o().send(e)}});export{u as n,d as t};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{a as e,r as t}from"./compiler-runtime.js";import{t as n}from"./useRouter.js";var r=e(t(),1);function i(e){let t=n();return r.useCallback(n=>t.navigate({...n,from:n.from??e?.from}),[e?.from,t])}export{i as t};
|
||||
import{a as e,r as t}from"./compiler-runtime.js";import{i as n}from"./useStore.js";var r=e(t(),1);function i(e){let t=n();return r.useCallback(n=>t.navigate({...n,from:n.from??e?.from}),[e?.from,t])}export{i as t};
|
||||
@@ -1 +0,0 @@
|
||||
import{a as e,r as t}from"./compiler-runtime.js";var n=e(t(),1),r=n.use,i=typeof window<`u`?n.useLayoutEffect:n.useEffect;function a(e){let t=n.useRef({value:e,prev:null}),r=t.current.value;return e!==r&&(t.current={value:e,prev:r}),t.current.prev}function o(e,t,r={},i={}){n.useEffect(()=>{if(!e.current||i.disabled||typeof IntersectionObserver!=`function`)return;let n=new IntersectionObserver(([e])=>{t(e)},r);return n.observe(e.current),()=>{n.disconnect()}},[t,r,i.disabled,e])}function s(e){let t=n.useRef(null);return n.useImperativeHandle(e,()=>t.current,[]),t}var c=n.createContext(null);function l(e){return n.useContext(c)}export{o as a,s as i,c as n,i as o,r,a as s,l as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -26,7 +26,6 @@
|
||||
<link rel="modulepreload" crossorigin href="/assets/useOpenInteractionType.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/separator.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/QueryClientProvider.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/useRouter.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/link.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/matchContext.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/useNavigate.js">
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { EC2Client } from "@aws-sdk/client-ec2"
|
||||
import { IAMClient } from "@aws-sdk/client-iam"
|
||||
import { S3Client } from "@aws-sdk/client-s3"
|
||||
import { HttpRequest } from "@smithy/protocol-http"
|
||||
|
||||
import { getCredentials } from "./auth"
|
||||
|
||||
const AWS_REGION = "ap-southeast-2"
|
||||
const awsEndpoint = `${window.location.protocol}//${window.location.hostname}:9999`
|
||||
const s3Endpoint = `${window.location.protocol}//${window.location.hostname}:8443`
|
||||
|
||||
// SDK signs against the real backend host so the SigV4 signature includes
|
||||
// the correct Host header value. Middleware rewrites the outgoing URL
|
||||
// to route through the same-origin reverse proxy after signing is complete.
|
||||
const AWSGW_SIGN_ENDPOINT = `${window.location.protocol}//localhost:9999`
|
||||
const S3_SIGN_ENDPOINT = `${window.location.protocol}//localhost:8443`
|
||||
|
||||
// Cached singleton clients
|
||||
let ec2Client: EC2Client | null = null
|
||||
@@ -20,13 +25,24 @@ export function getEc2Client(): EC2Client {
|
||||
throw new Error("AWS credentials not configured")
|
||||
}
|
||||
ec2Client = new EC2Client({
|
||||
endpoint: awsEndpoint,
|
||||
endpoint: AWSGW_SIGN_ENDPOINT,
|
||||
region: AWS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: credentials.accessKeyId,
|
||||
secretAccessKey: credentials.secretAccessKey,
|
||||
},
|
||||
})
|
||||
ec2Client.middlewareStack.add(
|
||||
(next) => (args) => {
|
||||
if (HttpRequest.isInstance(args.request)) {
|
||||
args.request.hostname = window.location.hostname
|
||||
args.request.port = Number(window.location.port) || 443
|
||||
args.request.path = `/proxy/awsgw${args.request.path}`
|
||||
}
|
||||
return next(args)
|
||||
},
|
||||
{ step: "finalizeRequest", name: "proxyRewrite", override: true },
|
||||
)
|
||||
}
|
||||
return ec2Client
|
||||
}
|
||||
@@ -38,13 +54,24 @@ export function getIamClient(): IAMClient {
|
||||
throw new Error("AWS credentials not configured")
|
||||
}
|
||||
iamClient = new IAMClient({
|
||||
endpoint: awsEndpoint,
|
||||
endpoint: AWSGW_SIGN_ENDPOINT,
|
||||
region: AWS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: credentials.accessKeyId,
|
||||
secretAccessKey: credentials.secretAccessKey,
|
||||
},
|
||||
})
|
||||
iamClient.middlewareStack.add(
|
||||
(next) => (args) => {
|
||||
if (HttpRequest.isInstance(args.request)) {
|
||||
args.request.hostname = window.location.hostname
|
||||
args.request.port = Number(window.location.port) || 443
|
||||
args.request.path = `/proxy/awsgw${args.request.path}`
|
||||
}
|
||||
return next(args)
|
||||
},
|
||||
{ step: "finalizeRequest", name: "proxyRewrite", override: true },
|
||||
)
|
||||
}
|
||||
return iamClient
|
||||
}
|
||||
@@ -56,7 +83,7 @@ export function getS3Client(): S3Client {
|
||||
throw new Error("AWS credentials not configured")
|
||||
}
|
||||
s3Client = new S3Client({
|
||||
endpoint: s3Endpoint,
|
||||
endpoint: S3_SIGN_ENDPOINT,
|
||||
region: AWS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: credentials.accessKeyId,
|
||||
@@ -67,21 +94,30 @@ export function getS3Client(): S3Client {
|
||||
// Remove trailing slashes from request paths to fix compatibility with
|
||||
// path-style S3 endpoints where a trailing slash causes the request to
|
||||
// be interpreted as GetObject instead of ListObjects
|
||||
s3Client.middlewareStack.use({
|
||||
applyToStack: (stack) => {
|
||||
stack.add(
|
||||
(next) => (args) => {
|
||||
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- smithy middleware args.request is typed as unknown
|
||||
const request = (args as { request?: { path?: string } }).request
|
||||
if (request?.path?.endsWith("/") && request.path !== "/") {
|
||||
request.path = request.path.slice(0, -1)
|
||||
}
|
||||
return next(args)
|
||||
},
|
||||
{ step: "build", name: "removeTrailingSlash" },
|
||||
)
|
||||
s3Client.middlewareStack.add(
|
||||
(next) => (args) => {
|
||||
if (
|
||||
HttpRequest.isInstance(args.request) &&
|
||||
args.request.path.endsWith("/") &&
|
||||
args.request.path !== "/"
|
||||
) {
|
||||
args.request.path = args.request.path.slice(0, -1)
|
||||
}
|
||||
return next(args)
|
||||
},
|
||||
})
|
||||
{ step: "build", name: "removeTrailingSlash" },
|
||||
)
|
||||
s3Client.middlewareStack.add(
|
||||
(next) => (args) => {
|
||||
if (HttpRequest.isInstance(args.request)) {
|
||||
args.request.hostname = window.location.hostname
|
||||
args.request.port = Number(window.location.port) || 443
|
||||
args.request.path = `/proxy/s3${args.request.path}`
|
||||
}
|
||||
return next(args)
|
||||
},
|
||||
{ step: "finalizeRequest", name: "proxyRewrite", override: true },
|
||||
)
|
||||
}
|
||||
return s3Client
|
||||
}
|
||||
|
||||
@@ -18,19 +18,19 @@ export async function signedFetch<T>({
|
||||
credentials,
|
||||
service = "spinifex",
|
||||
}: SignedFetchOptions): Promise<T> {
|
||||
const hostname = window.location.hostname
|
||||
const port = GATEWAY_PORT
|
||||
const protocol = window.location.protocol.replace(":", "")
|
||||
const body = `Action=${action}`
|
||||
|
||||
// Sign the request against the real backend (localhost:9999) so the
|
||||
// gateway's SigV4 verification sees the host value it expects.
|
||||
const request = new HttpRequest({
|
||||
method: "POST",
|
||||
protocol,
|
||||
hostname,
|
||||
port,
|
||||
hostname: "localhost",
|
||||
port: GATEWAY_PORT,
|
||||
path: "/",
|
||||
headers: {
|
||||
host: `${hostname}:${port}`,
|
||||
host: `localhost:${GATEWAY_PORT}`,
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
@@ -55,15 +55,20 @@ export async function signedFetch<T>({
|
||||
}
|
||||
}
|
||||
|
||||
const url = `${protocol}://${hostname}:${port}/`
|
||||
const response = await fetch(url, {
|
||||
// Send the request through the same-origin reverse proxy instead of
|
||||
// directly to the gateway, eliminating cross-origin requests.
|
||||
const proxyUrl = `${window.location.protocol}//${window.location.host}/proxy/awsgw/`
|
||||
const response = await fetch(proxyUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${action} failed: ${response.status}`)
|
||||
const detail = await response.text().catch(() => "")
|
||||
throw new Error(
|
||||
`${action} failed: ${response.status}${detail ? ` - ${detail}` : ""}`,
|
||||
)
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- response.json() returns Promise<any>
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock("@/lib/awsClient", () => ({
|
||||
|
||||
vi.mock("@aws-sdk/lib-storage", () => ({
|
||||
Upload: class MockUpload {
|
||||
private key: string
|
||||
private readonly key: string
|
||||
constructor({ params }: { params: { Key: string } }) {
|
||||
this.key = params.Key
|
||||
}
|
||||
|
||||
+3
-4
@@ -12,7 +12,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
|
||||
|
||||
interface PrivateKeyModalProps {
|
||||
@@ -59,10 +58,10 @@ export function PrivateKeyModal({
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
className="font-mono text-xs"
|
||||
<textarea
|
||||
className="w-full resize-none overflow-y-auto rounded-md border border-input bg-input/20 px-2 py-2 font-mono text-xs"
|
||||
readOnly
|
||||
rows={12}
|
||||
rows={4}
|
||||
value={keyMaterial}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DescribeInstancesCommand } from "@aws-sdk/client-ec2"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
@@ -37,7 +37,6 @@ export const Route = createFileRoute("/login")({
|
||||
})
|
||||
|
||||
function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [authError, setAuthError] = useState<string | null>(null)
|
||||
const {
|
||||
register,
|
||||
@@ -54,7 +53,7 @@ function LoginPage() {
|
||||
setCredentials(data)
|
||||
try {
|
||||
await getEc2Client().send(new DescribeInstancesCommand({}))
|
||||
navigate({ to: "/" })
|
||||
window.location.href = "/"
|
||||
} catch {
|
||||
clearCredentials()
|
||||
clearClients()
|
||||
|
||||
@@ -20,7 +20,7 @@ const keyNameField = z
|
||||
export const createInstanceSchema = z.object({
|
||||
imageId: z.string("Please select an Image"),
|
||||
instanceType: z.string("Please select an instance type"),
|
||||
keyName: z.string("Please select a key pair"),
|
||||
keyName: z.string("Please select a key pair").min(1, "Key pair is required"),
|
||||
subnetId: z.string().optional(),
|
||||
placementGroupName: z.string().optional(),
|
||||
count: z
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package spinifexui
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newProxyTransport creates an *http.Transport that trusts the given CA
|
||||
// certificate so the reverse proxy can connect to backend services using
|
||||
// self-signed TLS certificates.
|
||||
func newProxyTransport(caCertPath string) (*http.Transport, error) {
|
||||
caCert, err := os.ReadFile(caCertPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CA cert %s: %w", caCertPath, err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caCert) {
|
||||
return nil, fmt.Errorf("failed to parse CA cert from %s", caCertPath)
|
||||
}
|
||||
return &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: pool,
|
||||
},
|
||||
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 30 * time.Second,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// newReverseProxy creates a reverse proxy that forwards requests to the given
|
||||
// backend host:port after stripping the pathPrefix from the request path.
|
||||
// The proxy sets req.Host to the backend address so SigV4 signature verification
|
||||
// succeeds (the gateway uses r.Host for the canonical host header).
|
||||
func newReverseProxy(backendHost, pathPrefix string, transport *http.Transport) http.Handler {
|
||||
target := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: backendHost,
|
||||
}
|
||||
|
||||
proxy := &httputil.ReverseProxy{
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(target)
|
||||
pr.Out.Host = target.Host
|
||||
|
||||
// Strip the proxy path prefix.
|
||||
pr.Out.URL.Path = strings.TrimPrefix(pr.In.URL.Path, pathPrefix)
|
||||
if pr.Out.URL.Path == "" {
|
||||
pr.Out.URL.Path = "/"
|
||||
}
|
||||
},
|
||||
Transport: transport,
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.Error("Proxy error", "backend", backendHost, "path", r.URL.Path, "error", err)
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>`+
|
||||
`<Error><Code>BadGateway</Code>`+
|
||||
`<Message>upstream connection to %s failed</Message></Error>`, backendHost)
|
||||
},
|
||||
}
|
||||
|
||||
return proxy
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
package spinifexui
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -21,6 +17,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
)
|
||||
|
||||
@@ -149,24 +146,17 @@ func (svc *Service) launchService() error {
|
||||
// Derive CA cert path from server cert directory.
|
||||
caCertPath := filepath.Join(filepath.Dir(svc.Config.TLSCert), "ca.pem")
|
||||
|
||||
// Build TLS transport for reverse proxies using the same CA the UI trusts.
|
||||
proxyTransport, err := newProxyTransport(caCertPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("proxy transport: %w", err)
|
||||
}
|
||||
|
||||
// Serve static files from embedded filesystem
|
||||
fileServer := http.FileServer(http.FS(contentFS))
|
||||
|
||||
// SPA handler: try to serve the file, fallback to index.html
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Serve CA certificate download at /api/ca.pem.
|
||||
if r.URL.Path == "/api/ca.pem" {
|
||||
if _, err := os.Stat(caCertPath); os.IsNotExist(err) {
|
||||
slog.Warn("CA certificate requested but not found", "path", caCertPath)
|
||||
http.Error(w, "CA certificate not yet generated. Run 'spx admin init' to create it.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="spinifex-ca.pem"`)
|
||||
http.ServeFile(w, r, caCertPath)
|
||||
return
|
||||
}
|
||||
|
||||
spaHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Clean the path
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if path == "" {
|
||||
@@ -197,8 +187,38 @@ func (svc *Service) launchService() error {
|
||||
}
|
||||
})
|
||||
|
||||
// Wrap handler with security headers and gzip compression
|
||||
finalHandler := securityHeadersMiddleware(gzipMiddleware(handler))
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Reverse proxy routes — must be registered before the SPA catch-all.
|
||||
mux.Handle("/proxy/awsgw/", newReverseProxy("localhost:9999", "/proxy/awsgw", proxyTransport))
|
||||
mux.Handle("/proxy/s3/", newReverseProxy("localhost:8443", "/proxy/s3", proxyTransport))
|
||||
|
||||
// CA certificate download.
|
||||
mux.HandleFunc("/api/ca.pem", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := os.Stat(caCertPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
slog.Warn("CA certificate requested but not found", "path", caCertPath)
|
||||
http.Error(w, "CA certificate not yet generated. Run 'spx admin init' to create it.", http.StatusNotFound)
|
||||
} else {
|
||||
slog.Error("CA certificate stat failed", "path", caCertPath, "error", err)
|
||||
http.Error(w, "Unable to read CA certificate", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="spinifex-ca.pem"`)
|
||||
http.ServeFile(w, r, caCertPath)
|
||||
})
|
||||
|
||||
// SPA catch-all.
|
||||
mux.Handle("/", spaHandler)
|
||||
|
||||
// Chi's Compress middleware handles http.Flusher passthrough for streaming
|
||||
// proxy responses and skips already-compressed content types.
|
||||
compressor := middleware.NewCompressor(5, "text/html", "text/css",
|
||||
"application/javascript", "text/javascript", "application/json",
|
||||
"image/svg+xml", "text/plain")
|
||||
finalHandler := securityHeadersMiddleware(compressor.Handler(mux))
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", svc.Config.Host, svc.Config.Port)
|
||||
|
||||
@@ -206,7 +226,7 @@ func (svc *Service) launchService() error {
|
||||
Addr: addr,
|
||||
Handler: finalHandler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
WriteTimeout: 120 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
@@ -256,8 +276,14 @@ func (svc *Service) launchService() error {
|
||||
return server.Serve(splitLn)
|
||||
}
|
||||
|
||||
// Content-Security-Policy header. All API requests are proxied through the
|
||||
// same origin so connect-src only needs 'self'.
|
||||
const csp = "default-src 'self'; script-src 'self'; style-src 'self'; " +
|
||||
"img-src 'self'; font-src 'self' data:; connect-src 'self'; " +
|
||||
"object-src 'none'; base-uri 'self'; form-action 'self'; " +
|
||||
"frame-ancestors 'none'; upgrade-insecure-requests;"
|
||||
|
||||
func securityHeadersMiddleware(next http.Handler) http.Handler {
|
||||
csp := buildCSP()
|
||||
slog.Info("Content-Security-Policy configured", "csp", csp)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", csp)
|
||||
@@ -268,205 +294,3 @@ func securityHeadersMiddleware(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// getLocalIPs returns all non-loopback IPv4 addresses on the machine.
|
||||
func getLocalIPs() []string {
|
||||
var ips []string
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get network interfaces for CSP", "error", err)
|
||||
return ips
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if ip == nil || ip.IsLoopback() || ip.To4() == nil {
|
||||
continue
|
||||
}
|
||||
ips = append(ips, ip.String())
|
||||
}
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
// buildCSP constructs the Content-Security-Policy header, dynamically adding
|
||||
// each local IP with the AWS Gateway (:9999) and daemon (:8443) ports to connect-src.
|
||||
func buildCSP() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("'self' https://localhost:9999 https://localhost:8443")
|
||||
for _, ip := range getLocalIPs() {
|
||||
fmt.Fprintf(&b, " https://%s:9999 https://%s:8443", ip, ip)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self' data:; connect-src %s; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests;",
|
||||
b.String(),
|
||||
)
|
||||
}
|
||||
|
||||
// gzipContentTypes lists the MIME types eligible for gzip compression.
|
||||
var gzipContentTypes = map[string]bool{
|
||||
"text/html": true,
|
||||
"text/css": true,
|
||||
"application/javascript": true,
|
||||
"text/javascript": true,
|
||||
"application/json": true,
|
||||
"image/svg+xml": true,
|
||||
"text/plain": true,
|
||||
}
|
||||
|
||||
// gzipResponseWriter wraps http.ResponseWriter to compress eligible responses.
|
||||
type gzipResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
gw *gzip.Writer
|
||||
wroteHeader bool
|
||||
compress bool
|
||||
}
|
||||
|
||||
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
if !w.wroteHeader {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
if w.compress {
|
||||
return w.gw.Write(b)
|
||||
}
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (w *gzipResponseWriter) WriteHeader(code int) {
|
||||
if w.wroteHeader {
|
||||
return
|
||||
}
|
||||
w.wroteHeader = true
|
||||
|
||||
ct := w.ResponseWriter.Header().Get("Content-Type")
|
||||
// Strip parameters (e.g. "text/html; charset=utf-8" → "text/html")
|
||||
if idx := strings.IndexByte(ct, ';'); idx != -1 {
|
||||
ct = strings.TrimSpace(ct[:idx])
|
||||
}
|
||||
if gzipContentTypes[ct] {
|
||||
w.compress = true
|
||||
w.ResponseWriter.Header().Set("Content-Encoding", "gzip")
|
||||
w.ResponseWriter.Header().Del("Content-Length")
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func gzipMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
gz, _ := gzip.NewWriterLevel(w, gzip.DefaultCompression)
|
||||
defer gz.Close()
|
||||
|
||||
grw := &gzipResponseWriter{ResponseWriter: w, gw: gz}
|
||||
next.ServeHTTP(grw, r)
|
||||
})
|
||||
}
|
||||
|
||||
const tlsRecordTypeHandshake = 0x16
|
||||
|
||||
// tlsSplitListener accepts connections and reads the first byte. If it looks
|
||||
// like a TLS ClientHello (0x16), the connection is wrapped with TLS. Otherwise,
|
||||
// a plain-HTTP redirect to HTTPS is sent and the connection is closed.
|
||||
type tlsSplitListener struct {
|
||||
net.Listener
|
||||
|
||||
port int
|
||||
tlsCfg *tls.Config
|
||||
}
|
||||
|
||||
func (ln *tlsSplitListener) Accept() (net.Conn, error) {
|
||||
for {
|
||||
conn, err := ln.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set a deadline for the initial protocol detection byte so a
|
||||
// slow/malicious client cannot block the accept loop.
|
||||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
var buf [1]byte
|
||||
if _, err := io.ReadFull(conn, buf[:]); err != nil {
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
// Clear the deadline before handing off the connection.
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
|
||||
if buf[0] == tlsRecordTypeHandshake {
|
||||
// Prepend the peeked byte and upgrade to TLS.
|
||||
merged := &prefixConn{
|
||||
Conn: conn,
|
||||
r: io.MultiReader(bytes.NewReader(buf[:]), conn),
|
||||
}
|
||||
return tls.Server(merged, ln.tlsCfg), nil
|
||||
}
|
||||
|
||||
// Plain HTTP — send redirect and close.
|
||||
go ln.redirectHTTP(conn, buf[0])
|
||||
}
|
||||
}
|
||||
|
||||
// redirectHTTP reads an HTTP request from conn (with the first byte already
|
||||
// consumed into firstByte), writes a 301 redirect to HTTPS, and closes conn.
|
||||
func (ln *tlsSplitListener) redirectHTTP(conn net.Conn, firstByte byte) {
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
br := bufio.NewReader(io.MultiReader(bytes.NewReader([]byte{firstByte}), conn))
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer req.Body.Close()
|
||||
|
||||
host := req.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
|
||||
// Sanitize host to prevent header injection via CRLF.
|
||||
if strings.ContainsAny(host, "\r\n") {
|
||||
return
|
||||
}
|
||||
|
||||
target := fmt.Sprintf("https://%s:%d%s", host, ln.port, req.URL.RequestURI())
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusMovedPermanently,
|
||||
ProtoMajor: 1,
|
||||
ProtoMinor: 1,
|
||||
Header: http.Header{"Location": {target}, "Connection": {"close"}},
|
||||
Body: http.NoBody,
|
||||
}
|
||||
_ = resp.Write(conn)
|
||||
}
|
||||
|
||||
// prefixConn wraps a net.Conn with a reader that replays prefixed bytes.
|
||||
type prefixConn struct {
|
||||
net.Conn
|
||||
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
func (c *prefixConn) Read(b []byte) (int, error) {
|
||||
return c.r.Read(b)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@ package spinifexui
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"crypto/tls"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -168,14 +173,15 @@ func TestSecurityHeadersMiddleware_PassesThrough(t *testing.T) {
|
||||
assert.NotEmpty(t, resp.Header.Get("Content-Security-Policy"))
|
||||
}
|
||||
|
||||
func TestGzipMiddleware_CompressesEligibleContent(t *testing.T) {
|
||||
func TestChiCompress_CompressesEligibleContent(t *testing.T) {
|
||||
body := "Hello, this is some text content that should be compressed if long enough."
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(body))
|
||||
})
|
||||
|
||||
handler := gzipMiddleware(inner)
|
||||
compressor := middleware.NewCompressor(5, "text/html")
|
||||
handler := compressor.Handler(inner)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -185,8 +191,6 @@ func TestGzipMiddleware_CompressesEligibleContent(t *testing.T) {
|
||||
resp := rec.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
// The gzip handler may or may not compress depending on content size,
|
||||
// but the handler should still return a valid response.
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
if resp.Header.Get("Content-Encoding") == "gzip" {
|
||||
@@ -199,16 +203,16 @@ func TestGzipMiddleware_CompressesEligibleContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGzipMiddleware_NoCompressionWithoutHeader(t *testing.T) {
|
||||
func TestChiCompress_NoCompressionWithoutHeader(t *testing.T) {
|
||||
body := "uncompressed response body"
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(body))
|
||||
})
|
||||
|
||||
handler := gzipMiddleware(inner)
|
||||
compressor := middleware.NewCompressor(5, "text/html")
|
||||
handler := compressor.Handler(inner)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
// No Accept-Encoding header
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
@@ -223,13 +227,14 @@ func TestGzipMiddleware_NoCompressionWithoutHeader(t *testing.T) {
|
||||
assert.Equal(t, body, string(respBody))
|
||||
}
|
||||
|
||||
func TestGzipMiddleware_IgnoresNonTextContent(t *testing.T) {
|
||||
func TestChiCompress_IgnoresNonTextContent(t *testing.T) {
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Write([]byte("fake image data"))
|
||||
})
|
||||
|
||||
handler := gzipMiddleware(inner)
|
||||
compressor := middleware.NewCompressor(5, "text/html", "application/json")
|
||||
handler := compressor.Handler(inner)
|
||||
req := httptest.NewRequest(http.MethodGet, "/image.png", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -240,36 +245,161 @@ func TestGzipMiddleware_IgnoresNonTextContent(t *testing.T) {
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
// image/png not in the allowed content types — should not be compressed
|
||||
assert.NotEqual(t, "gzip", resp.Header.Get("Content-Encoding"))
|
||||
}
|
||||
|
||||
func TestGetLocalIPs_ReturnsNonLoopback(t *testing.T) {
|
||||
ips := getLocalIPs()
|
||||
for _, ip := range ips {
|
||||
assert.NotEqual(t, "127.0.0.1", ip, "should not include loopback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCSP_ContainsSelf(t *testing.T) {
|
||||
csp := buildCSP()
|
||||
func TestCSP_ContainsSelf(t *testing.T) {
|
||||
assert.Contains(t, csp, "connect-src 'self'")
|
||||
assert.Contains(t, csp, "default-src 'self'")
|
||||
}
|
||||
|
||||
func TestBuildCSP_ContainsLocalIPs(t *testing.T) {
|
||||
ips := getLocalIPs()
|
||||
csp := buildCSP()
|
||||
for _, ip := range ips {
|
||||
assert.Contains(t, csp, "https://"+ip+":9999")
|
||||
assert.Contains(t, csp, "https://"+ip+":8443")
|
||||
func TestCSP_NoExternalPorts(t *testing.T) {
|
||||
assert.NotContains(t, csp, ":9999", "proxy removes need for direct gateway access")
|
||||
assert.NotContains(t, csp, ":8443", "proxy removes need for direct predastore access")
|
||||
}
|
||||
|
||||
func TestNewReverseProxy_StripsPrefixAndSetsHost(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reqPath string
|
||||
prefix string
|
||||
wantPath string
|
||||
wantHost string
|
||||
wantQuery string
|
||||
}{
|
||||
{
|
||||
name: "strips awsgw prefix",
|
||||
reqPath: "/proxy/awsgw/",
|
||||
prefix: "/proxy/awsgw",
|
||||
wantPath: "/",
|
||||
wantHost: "localhost:9999",
|
||||
},
|
||||
{
|
||||
name: "strips prefix with subpath",
|
||||
reqPath: "/proxy/awsgw/some/path",
|
||||
prefix: "/proxy/awsgw",
|
||||
wantPath: "/some/path",
|
||||
wantHost: "localhost:9999",
|
||||
},
|
||||
{
|
||||
name: "strips s3 prefix with bucket key",
|
||||
reqPath: "/proxy/s3/bucket/key",
|
||||
prefix: "/proxy/s3",
|
||||
wantPath: "/bucket/key",
|
||||
wantHost: "localhost:8443",
|
||||
},
|
||||
{
|
||||
name: "empty path after strip becomes root",
|
||||
reqPath: "/proxy/awsgw",
|
||||
prefix: "/proxy/awsgw",
|
||||
wantPath: "/",
|
||||
wantHost: "localhost:9999",
|
||||
},
|
||||
{
|
||||
name: "preserves query string",
|
||||
reqPath: "/proxy/s3/bucket?list-type=2&prefix=foo",
|
||||
prefix: "/proxy/s3",
|
||||
wantPath: "/bucket",
|
||||
wantHost: "localhost:8443",
|
||||
wantQuery: "list-type=2&prefix=foo",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Start a mock backend to capture the forwarded request.
|
||||
var gotPath, gotHost, gotQuery string
|
||||
backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotHost = r.Host
|
||||
gotQuery = r.URL.RawQuery
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
// Use the test server's TLS transport.
|
||||
transport, ok := backend.Client().Transport.(*http.Transport)
|
||||
require.True(t, ok, "expected *http.Transport")
|
||||
|
||||
// Point the proxy at the test server instead of the real backend.
|
||||
backendAddr := backend.Listener.Addr().String()
|
||||
proxy := newReverseProxy(backendAddr, tt.prefix, transport)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, tt.reqPath, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, tt.wantPath, gotPath, "backend should see stripped path")
|
||||
assert.Equal(t, backendAddr, gotHost, "backend should see proxy-set Host")
|
||||
if tt.wantQuery != "" {
|
||||
assert.Equal(t, tt.wantQuery, gotQuery, "query string should be preserved")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCSP_ContainsLocalhost(t *testing.T) {
|
||||
csp := buildCSP()
|
||||
assert.Contains(t, csp, "https://localhost:9999")
|
||||
assert.Contains(t, csp, "https://localhost:8443")
|
||||
func TestNewReverseProxy_ErrorHandler(t *testing.T) {
|
||||
// Use a transport that will fail to connect (no backend listening).
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
|
||||
proxy := newReverseProxy("localhost:19999", "/proxy/awsgw", transport)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/proxy/awsgw/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadGateway, rec.Code)
|
||||
assert.Equal(t, "application/xml", rec.Header().Get("Content-Type"))
|
||||
body := rec.Body.String()
|
||||
assert.Contains(t, body, "<Code>BadGateway</Code>")
|
||||
assert.Contains(t, body, "localhost:19999")
|
||||
}
|
||||
|
||||
func TestNewProxyTransport_ValidCert(t *testing.T) {
|
||||
// Use the test TLS server's CA cert.
|
||||
backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer backend.Close()
|
||||
|
||||
// Extract the CA cert from the test server's TLS config and write to a temp file.
|
||||
certPEM := backend.Certificate()
|
||||
require.NotNil(t, certPEM)
|
||||
|
||||
dir := t.TempDir()
|
||||
caPath := filepath.Join(dir, "ca.pem")
|
||||
|
||||
// httptest TLS server uses a self-signed cert; encode it as PEM.
|
||||
pemData := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: certPEM.Raw,
|
||||
})
|
||||
require.NoError(t, os.WriteFile(caPath, pemData, 0o644))
|
||||
|
||||
transport, err := newProxyTransport(caPath)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, transport)
|
||||
assert.NotNil(t, transport.TLSClientConfig)
|
||||
assert.NotNil(t, transport.TLSClientConfig.RootCAs)
|
||||
}
|
||||
|
||||
func TestNewProxyTransport_MissingFile(t *testing.T) {
|
||||
_, err := newProxyTransport("/nonexistent/ca.pem")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "read CA cert")
|
||||
}
|
||||
|
||||
func TestNewProxyTransport_InvalidPEM(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caPath := filepath.Join(dir, "bad.pem")
|
||||
require.NoError(t, os.WriteFile(caPath, []byte("not a cert"), 0o644))
|
||||
|
||||
_, err := newProxyTransport(caPath)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to parse CA cert")
|
||||
}
|
||||
|
||||
func TestShutdown_WithServer(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package spinifexui
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tlsRecordTypeHandshake = 0x16
|
||||
|
||||
// tlsSplitListener accepts connections and reads the first byte. If it looks
|
||||
// like a TLS ClientHello (0x16), the connection is wrapped with TLS. Otherwise,
|
||||
// a plain-HTTP redirect to HTTPS is sent and the connection is closed.
|
||||
type tlsSplitListener struct {
|
||||
net.Listener
|
||||
|
||||
port int
|
||||
tlsCfg *tls.Config
|
||||
}
|
||||
|
||||
func (ln *tlsSplitListener) Accept() (net.Conn, error) {
|
||||
for {
|
||||
conn, err := ln.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set a deadline for the initial protocol detection byte so a
|
||||
// slow/malicious client cannot block the accept loop.
|
||||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
var buf [1]byte
|
||||
if _, err := io.ReadFull(conn, buf[:]); err != nil {
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
// Clear the deadline before handing off the connection.
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
|
||||
if buf[0] == tlsRecordTypeHandshake {
|
||||
// Prepend the peeked byte and upgrade to TLS.
|
||||
merged := &prefixConn{
|
||||
Conn: conn,
|
||||
r: io.MultiReader(bytes.NewReader(buf[:]), conn),
|
||||
}
|
||||
return tls.Server(merged, ln.tlsCfg), nil
|
||||
}
|
||||
|
||||
// Plain HTTP — send redirect and close.
|
||||
go ln.redirectHTTP(conn, buf[0])
|
||||
}
|
||||
}
|
||||
|
||||
// redirectHTTP reads an HTTP request from conn (with the first byte already
|
||||
// consumed into firstByte), writes a 301 redirect to HTTPS, and closes conn.
|
||||
func (ln *tlsSplitListener) redirectHTTP(conn net.Conn, firstByte byte) {
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
|
||||
br := bufio.NewReader(io.MultiReader(bytes.NewReader([]byte{firstByte}), conn))
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer req.Body.Close()
|
||||
|
||||
host := req.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
|
||||
// Sanitize host to prevent header injection via CRLF.
|
||||
if strings.ContainsAny(host, "\r\n") {
|
||||
return
|
||||
}
|
||||
|
||||
target := "https://" + net.JoinHostPort(host, strconv.Itoa(ln.port)) + req.URL.RequestURI()
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusMovedPermanently,
|
||||
ProtoMajor: 1,
|
||||
ProtoMinor: 1,
|
||||
Header: http.Header{"Location": {target}, "Connection": {"close"}},
|
||||
Body: http.NoBody,
|
||||
}
|
||||
_ = resp.Write(conn)
|
||||
}
|
||||
|
||||
// prefixConn wraps a net.Conn with a reader that replays prefixed bytes.
|
||||
type prefixConn struct {
|
||||
net.Conn
|
||||
|
||||
r io.Reader
|
||||
}
|
||||
|
||||
func (c *prefixConn) Read(b []byte) (int, error) {
|
||||
return c.r.Read(b)
|
||||
}
|
||||
@@ -601,6 +601,14 @@ func launchService(cfg *Config) (err error) {
|
||||
slog.Info("NBDKit started successfully and is running")
|
||||
}
|
||||
|
||||
// NBDKit creates the socket with its own umask (typically 0755).
|
||||
// The daemon (different user, same group) needs write access to connect.
|
||||
if nbdSocket != "" {
|
||||
if err := os.Chmod(nbdSocket, 0770); err != nil { //nolint:gosec // socket needs group-write for cross-service access
|
||||
slog.Warn("Failed to chmod NBD socket", "socket", nbdSocket, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
ebsResponse.Mounted = true
|
||||
ebsResponse.URI = nbdURI
|
||||
|
||||
|
||||
@@ -95,7 +95,9 @@ func TestTopologyHandler_VPCDelete(t *testing.T) {
|
||||
t.Fatalf("request vpc.delete: %v", err)
|
||||
}
|
||||
|
||||
var result struct{ Success bool }
|
||||
var result struct {
|
||||
Success bool `json:"Success"`
|
||||
}
|
||||
_ = json.Unmarshal(resp.Data, &result)
|
||||
if !result.Success {
|
||||
t.Fatal("vpc.delete failed")
|
||||
|
||||
+28
-28
@@ -3,19 +3,19 @@ package types
|
||||
import "sync"
|
||||
|
||||
type EBSRequests struct {
|
||||
Requests []EBSRequest `mapstructure:"ebs_requests"`
|
||||
Requests []EBSRequest `json:"Requests" mapstructure:"ebs_requests"`
|
||||
Mu sync.Mutex `json:"-"`
|
||||
}
|
||||
|
||||
type EBSRequest struct {
|
||||
Name string
|
||||
VolType string
|
||||
Boot bool
|
||||
EFI bool
|
||||
CloudInit bool
|
||||
DeleteOnTermination bool
|
||||
NBDURI string // NBD URI - socket path (nbd:unix:/path.sock) or TCP (nbd://host:port)
|
||||
DeviceName string // AWS API device name (e.g. /dev/sdf) for hot-plugged volumes
|
||||
Name string `json:"Name"`
|
||||
VolType string `json:"VolType"`
|
||||
Boot bool `json:"Boot"`
|
||||
EFI bool `json:"EFI"`
|
||||
CloudInit bool `json:"CloudInit"`
|
||||
DeleteOnTermination bool `json:"DeleteOnTermination"`
|
||||
NBDURI string `json:"NBDURI"` // NBD URI - socket path (nbd:unix:/path.sock) or TCP (nbd://host:port)
|
||||
DeviceName string `json:"DeviceName"` // AWS API device name (e.g. /dev/sdf) for hot-plugged volumes
|
||||
}
|
||||
|
||||
// NBDTransport defines the transport type for NBD connections
|
||||
@@ -29,44 +29,44 @@ const (
|
||||
)
|
||||
|
||||
type EBSMountResponse struct {
|
||||
URI string
|
||||
Mounted bool
|
||||
Error string
|
||||
URI string `json:"URI"`
|
||||
Mounted bool `json:"Mounted"`
|
||||
Error string `json:"Error"`
|
||||
}
|
||||
|
||||
type EBSUnMountResponse struct {
|
||||
Volume string
|
||||
Mounted bool
|
||||
Error string
|
||||
Volume string `json:"Volume"`
|
||||
Mounted bool `json:"Mounted"`
|
||||
Error string `json:"Error"`
|
||||
}
|
||||
|
||||
type EBSSyncRequest struct {
|
||||
Volume string
|
||||
Volume string `json:"Volume"`
|
||||
}
|
||||
|
||||
type EBSSyncResponse struct {
|
||||
Volume string
|
||||
Synced bool
|
||||
Error string
|
||||
Volume string `json:"Volume"`
|
||||
Synced bool `json:"Synced"`
|
||||
Error string `json:"Error"`
|
||||
}
|
||||
|
||||
type EBSDeleteRequest struct {
|
||||
Volume string
|
||||
Volume string `json:"Volume"`
|
||||
}
|
||||
|
||||
type EBSDeleteResponse struct {
|
||||
Volume string
|
||||
Success bool
|
||||
Error string
|
||||
Volume string `json:"Volume"`
|
||||
Success bool `json:"Success"`
|
||||
Error string `json:"Error"`
|
||||
}
|
||||
|
||||
type EBSSnapshotRequest struct {
|
||||
Volume string
|
||||
SnapshotID string
|
||||
Volume string `json:"Volume"`
|
||||
SnapshotID string `json:"SnapshotID"`
|
||||
}
|
||||
|
||||
type EBSSnapshotResponse struct {
|
||||
SnapshotID string
|
||||
Success bool
|
||||
Error string
|
||||
SnapshotID string `json:"SnapshotID"`
|
||||
Success bool `json:"Success"`
|
||||
Error string `json:"Error"`
|
||||
}
|
||||
|
||||
+17
-5
@@ -3,6 +3,7 @@ package utils
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -33,14 +34,25 @@ func GenerateUniqueSocketFile(volname string) (string, error) {
|
||||
return "", errors.New("volume name is required")
|
||||
}
|
||||
|
||||
pidPath := pidPath()
|
||||
if pidPath == "" {
|
||||
return "", errors.New("pid path is empty")
|
||||
dir := NBDSocketDir()
|
||||
if dir == "" {
|
||||
return "", errors.New("nbd socket directory is empty")
|
||||
}
|
||||
|
||||
timestamp := time.Now().UnixNano()
|
||||
filename := fmt.Sprintf("nbd-%s-%d.sock", volname, timestamp)
|
||||
return filepath.Join(pidPath, filename), nil
|
||||
return filepath.Join(dir, filename), nil
|
||||
}
|
||||
|
||||
// NBDSocketDir returns the directory for NBD Unix sockets.
|
||||
// Under systemd installs, this is /run/spinifex/nbd/.
|
||||
// In dev mode (no /run/spinifex/nbd/), falls back to pidPath().
|
||||
func NBDSocketDir() string {
|
||||
const systemdNBDDir = "/run/spinifex/nbd"
|
||||
if dirExists(systemdNBDDir) {
|
||||
return systemdNBDDir
|
||||
}
|
||||
return pidPath()
|
||||
}
|
||||
|
||||
// IsSocketURI returns true if the NBD URI is a Unix socket path.
|
||||
@@ -58,7 +70,7 @@ func FormatNBDSocketURI(socketPath string) string {
|
||||
// FormatNBDTCPURI formats a host:port as an NBD URI for QEMU.
|
||||
// Returns format: nbd://host:port
|
||||
func FormatNBDTCPURI(host string, port int) string {
|
||||
return fmt.Sprintf("nbd://%s:%d", host, port)
|
||||
return "nbd://" + net.JoinHostPort(host, strconv.Itoa(port))
|
||||
}
|
||||
|
||||
// ParseNBDURI parses an NBD URI into its components for QMP blockdev-add.
|
||||
|
||||
@@ -530,21 +530,36 @@ dump_all_node_logs() {
|
||||
echo "DUMPING LOGS FROM ALL NODES"
|
||||
echo "=========================================="
|
||||
|
||||
# Show running spx/nats processes for context
|
||||
echo ""
|
||||
echo "--- Running processes ---"
|
||||
ps auxw | grep -E 'spx|nats-server' | grep -v grep || echo "(none)"
|
||||
|
||||
for i in 1 2 3; do
|
||||
local data_dir="$HOME/node$i"
|
||||
local logs_dir="$data_dir/logs"
|
||||
|
||||
if [ -d "$logs_dir" ]; then
|
||||
echo ""
|
||||
echo "=== Node$i Logs ==="
|
||||
echo ""
|
||||
echo "=== Node$i ==="
|
||||
|
||||
# Show directory structure
|
||||
echo "--- $data_dir contents ---"
|
||||
sudo ls -la "$data_dir/" 2>/dev/null || echo "(dir not found)"
|
||||
|
||||
if sudo test -d "$logs_dir"; then
|
||||
echo "--- $logs_dir contents ---"
|
||||
sudo ls -la "$logs_dir/" 2>/dev/null || true
|
||||
|
||||
for log in nats predastore viperblock spinifex awsgw; do
|
||||
if [ -f "$logs_dir/$log.log" ]; then
|
||||
if sudo test -f "$logs_dir/$log.log"; then
|
||||
echo ""
|
||||
echo "--- $log.log (last 50 lines) ---"
|
||||
tail -50 "$logs_dir/$log.log" 2>/dev/null || echo "(empty or not accessible)"
|
||||
sudo tail -50 "$logs_dir/$log.log" 2>/dev/null || echo "(empty or not accessible)"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "--- $logs_dir not found, checking PID files ---"
|
||||
sudo find "$data_dir" -name '*.pid' -o -name '*.log' 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
@@ -11,9 +11,6 @@ set -euo pipefail
|
||||
# Ensure Go is on PATH (SSH non-interactive shells don't source .bashrc)
|
||||
export PATH="/usr/local/go/bin:$HOME/go/bin:$PATH"
|
||||
|
||||
# Ensure we are in the spinifex project root
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
# ==========================================================================
|
||||
# Argument parsing
|
||||
# ==========================================================================
|
||||
@@ -36,14 +33,12 @@ NATS_MONITOR_PORT=8222
|
||||
PREDASTORE_PORT=8443
|
||||
AWSGW_PORT=9999
|
||||
SSH_KEY_PATH="$HOME/.ssh/tf-user-ap-southeast-2"
|
||||
SPINIFEX_DATA_DIR="$HOME/spinifex"
|
||||
SPINIFEX_CONFIG="$SPINIFEX_DATA_DIR/config/spinifex.toml"
|
||||
SPINIFEX_BIN="./bin/spx"
|
||||
SPINIFEX_BIN="spx"
|
||||
|
||||
# Use Spinifex profile for AWS CLI
|
||||
export AWS_PROFILE=spinifex
|
||||
# Trust Spinifex CA for AWS CLI v2 (bundles its own Python/certifi, ignores system CA store)
|
||||
export AWS_CA_BUNDLE="$SPINIFEX_DATA_DIR/config/ca.pem"
|
||||
export AWS_CA_BUNDLE="/etc/spinifex/ca.pem"
|
||||
|
||||
# Track test results
|
||||
TESTS_PASSED=0
|
||||
@@ -106,19 +101,15 @@ dump_all_node_logs() {
|
||||
local ip="${NODE_IPS[$i]}"
|
||||
echo ""
|
||||
echo "=== Node $((i+1)) ($ip) ==="
|
||||
if [ "$ip" = "$LOCAL_IP" ]; then
|
||||
for f in "$SPINIFEX_DATA_DIR/logs/"*.log; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "--- $(basename "$f") (last 50 lines) ---"
|
||||
tail -50 "$f" 2>/dev/null || echo "(not found)"
|
||||
done
|
||||
else
|
||||
peer_ssh "$ip" 'for f in ~/spinifex/logs/*.log; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "--- $(basename "$f") (last 50 lines) ---"
|
||||
tail -50 "$f" 2>/dev/null || echo "(not found)"
|
||||
done' || echo "(node unreachable)"
|
||||
fi
|
||||
for svc in spinifex-nats spinifex-predastore spinifex-viperblock \
|
||||
spinifex-daemon spinifex-awsgw spinifex-vpcd; do
|
||||
echo "--- ${svc} (last 50 lines) ---"
|
||||
if [ "$ip" = "$LOCAL_IP" ]; then
|
||||
sudo journalctl -u "$svc" --no-pager -n 50 2>/dev/null || echo "(not found)"
|
||||
else
|
||||
peer_ssh "$ip" "sudo journalctl -u $svc --no-pager -n 50" 2>/dev/null || echo "(node unreachable)"
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
@@ -403,7 +394,7 @@ pass_test "Daemon readiness"
|
||||
|
||||
# Spinifex CLI: get nodes
|
||||
echo "Checking spx get nodes..."
|
||||
GET_NODES_OUTPUT=$($SPINIFEX_BIN get nodes --config "$SPINIFEX_CONFIG" --timeout 5s 2>/dev/null)
|
||||
GET_NODES_OUTPUT=$($SPINIFEX_BIN get nodes --timeout 5s 2>/dev/null)
|
||||
echo "$GET_NODES_OUTPUT"
|
||||
READY_COUNT=$(echo "$GET_NODES_OUTPUT" | grep -c "Ready" || true)
|
||||
if [ "$READY_COUNT" -ge 3 ]; then
|
||||
@@ -415,7 +406,7 @@ fi
|
||||
|
||||
# Spinifex CLI: get vms (should be empty)
|
||||
echo "Checking spx get vms (empty)..."
|
||||
GET_VMS_OUTPUT=$($SPINIFEX_BIN get vms --config "$SPINIFEX_CONFIG" --timeout 5s 2>/dev/null)
|
||||
GET_VMS_OUTPUT=$($SPINIFEX_BIN get vms --timeout 5s 2>/dev/null)
|
||||
echo "$GET_VMS_OUTPUT"
|
||||
pass_test "spx get vms (empty)"
|
||||
|
||||
@@ -500,7 +491,7 @@ fi
|
||||
# Verify spx get vms shows all instances
|
||||
echo ""
|
||||
echo "Verifying spx get vms..."
|
||||
GET_VMS_OUTPUT=$($SPINIFEX_BIN get vms --config "$SPINIFEX_CONFIG" --timeout 5s 2>/dev/null)
|
||||
GET_VMS_OUTPUT=$($SPINIFEX_BIN get vms --timeout 5s 2>/dev/null)
|
||||
echo "$GET_VMS_OUTPUT"
|
||||
for instance_id in "${INSTANCE_IDS[@]}"; do
|
||||
if ! echo "$GET_VMS_OUTPUT" | grep -q "$instance_id"; then
|
||||
@@ -787,8 +778,8 @@ echo "Stopping services on node2 ($NODE2_IP) to simulate node failure..."
|
||||
|
||||
# Stop services on node2 only — SPINIFEX_FORCE_LOCAL_STOP prevents coordinated
|
||||
# cluster shutdown which would kill all nodes via NATS
|
||||
peer_ssh "$NODE2_IP" "cd ~/Development/mulga/spinifex && SPINIFEX_FORCE_LOCAL_STOP=1 ./scripts/stop-dev.sh" || {
|
||||
echo " WARNING: stop-dev.sh returned non-zero (may be expected)"
|
||||
peer_ssh "$NODE2_IP" "sudo systemctl stop spinifex.target" || {
|
||||
echo " WARNING: systemctl stop returned non-zero (may be expected)"
|
||||
}
|
||||
|
||||
# Wait for NATS cluster to detect the failure and reform
|
||||
@@ -848,7 +839,7 @@ echo "Phase 9: Node Recovery"
|
||||
echo "========================================"
|
||||
echo "Restarting services on node2 ($NODE2_IP)..."
|
||||
|
||||
peer_ssh "$NODE2_IP" "cd ~/Development/mulga/spinifex && ./scripts/start-dev.sh" || {
|
||||
peer_ssh "$NODE2_IP" "sudo systemctl start spinifex.target" || {
|
||||
echo " ERROR: Failed to restart services on node2"
|
||||
fail_test "Node2 restart"
|
||||
}
|
||||
@@ -899,7 +890,7 @@ fi
|
||||
|
||||
# Verify spx get nodes shows 3 Ready again
|
||||
echo " Checking spx get nodes after recovery..."
|
||||
GET_NODES_RECOVER=$($SPINIFEX_BIN get nodes --config "$SPINIFEX_CONFIG" --timeout 10s 2>/dev/null || echo "")
|
||||
GET_NODES_RECOVER=$($SPINIFEX_BIN get nodes --timeout 10s 2>/dev/null || echo "")
|
||||
echo "$GET_NODES_RECOVER"
|
||||
READY_RECOVER=$(echo "$GET_NODES_RECOVER" | grep -c "Ready" || true)
|
||||
if [ "$READY_RECOVER" -ge 3 ]; then
|
||||
@@ -1252,7 +1243,7 @@ else
|
||||
echo "Step 4: Validating spread placement across nodes..."
|
||||
|
||||
# Use spx get vms to check node assignment
|
||||
SPX_VMS=$($SPINIFEX_BIN get vms --config "$SPINIFEX_CONFIG" --timeout 5s 2>/dev/null)
|
||||
SPX_VMS=$($SPINIFEX_BIN get vms --timeout 5s 2>/dev/null)
|
||||
echo "$SPX_VMS"
|
||||
|
||||
SPREAD_NODES=()
|
||||
|
||||
Reference in New Issue
Block a user