mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-11-06 10:58:16 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33ea70f5d4 | |||
| 6e4142bfb7 | |||
| 3c90b05865 | |||
| fca45ddbd0 | |||
| afd202a844 | |||
| 43aa243b17 | |||
| a98d398bdf | |||
| 73ad05eaf4 | |||
| b35c29f8ef | |||
| 5d478d58e7 | |||
| fa6b4d53af | |||
| d65cfe9ed1 | |||
| 3152e49191 | |||
| b0872ee2b6 | |||
| d3a9065343 |
@@ -32,8 +32,6 @@ load_mod qemu_fw_cfg || load_mod fw_cfg_sysfs || true
|
||||
load_mod failover || true
|
||||
load_mod net_failover || true
|
||||
load_mod virtio_net || true
|
||||
# lb-agent probes each data NIC's gateway on an AF_PACKET socket.
|
||||
load_mod af_packet || true
|
||||
|
||||
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
|
||||
i=0
|
||||
|
||||
@@ -16,8 +16,8 @@ load_mod() {
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# lambda-agent reads its identity from fw_cfg, or the configuration disk, and
|
||||
# seals it before anything else runs. virtio_net needs net_failover and failover, and the
|
||||
# lambda-agent reads its identity from fw_cfg and unloads qemu_fw_cfg before
|
||||
# anything else runs. virtio_net needs net_failover and failover, and the
|
||||
# insmod fallback resolves no dependencies, so load the chain bottom-up.
|
||||
load_mod qemu_fw_cfg || echo "[init] qemu_fw_cfg did not load" >/dev/console
|
||||
load_mod failover || true
|
||||
@@ -26,30 +26,8 @@ load_mod virtio_net || echo "[init] virtio_net did not load" >/dev/console
|
||||
|
||||
ip link set lo up
|
||||
|
||||
# Without fw_cfg data the executor attaches the same entries as a tar archive
|
||||
# on the second disk; lambda-agent reads them here and erases the disk before
|
||||
# the function starts. qemu_fw_cfg loads without a device, so test the entry.
|
||||
CFG_ROOT=/spinifex-cfg
|
||||
FWCFG_ROOT=/sys/firmware/qemu_fw_cfg
|
||||
if [ ! -f "$FWCFG_ROOT/by_name/opt/spinifex/netcfg/raw" ]; then
|
||||
load_mod virtio_blk || echo "[init] virtio_blk did not load" >/dev/console
|
||||
i=0
|
||||
while [ "$i" -lt 25 ] && [ ! -b /dev/vdb ]; do
|
||||
sleep 0.04
|
||||
i=$((i + 1))
|
||||
done
|
||||
if [ -b /dev/vdb ]; then
|
||||
mkdir -m 0700 "$CFG_ROOT"
|
||||
if ! mount -t tmpfs -o mode=0700,size=1m tmpfs "$CFG_ROOT" ||
|
||||
! tar -xf /dev/vdb -C "$CFG_ROOT"; then
|
||||
echo "[init] configuration disk not readable" >/dev/console
|
||||
fi
|
||||
FWCFG_ROOT=$CFG_ROOT
|
||||
fi
|
||||
fi
|
||||
|
||||
# The guest's addresses come from the same netcfg entry as other system VMs.
|
||||
NETCFG=$FWCFG_ROOT/by_name/opt/spinifex/netcfg/raw
|
||||
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
|
||||
i=0
|
||||
while [ "$i" -lt 25 ] && [ ! -f "$NETCFG" ]; do
|
||||
sleep 0.04
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
|
||||
@@ -26,22 +25,9 @@ type startup struct {
|
||||
}
|
||||
|
||||
func defaultStartup() startup {
|
||||
if _, err := os.Stat(hostlink.ConfigDiskRoot); err == nil {
|
||||
return configDiskStartup(hostlink.ConfigDiskRoot, hostlink.ConfigDiskDevice)
|
||||
}
|
||||
return startup{fwCfgDir: hostlink.FwCfgDir, fwCfgRoot: hostlink.FwCfgRoot, unload: hostlink.UnloadFwCfg}
|
||||
}
|
||||
|
||||
// configDiskStartup reads the entries init extracted from the configuration
|
||||
// disk, and seals by erasing the disk and removing the extracted tree.
|
||||
func configDiskStartup(root, device string) startup {
|
||||
return startup{
|
||||
fwCfgDir: root + "/by_name/" + lambdav1.FwCfgPrefix,
|
||||
fwCfgRoot: root,
|
||||
unload: func() error { return hostlink.SealConfigDisk(device, root) },
|
||||
}
|
||||
}
|
||||
|
||||
// connect loads the identity, seals fw_cfg whether or not the load succeeded,
|
||||
// then dials the host. Under TLS 1.3 a host refusal is seen only on first read.
|
||||
func (s startup) connect(ctx context.Context) (*tls.Conn, lambdav1.Scope, int, error) {
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
//test:in-package — package main cannot be imported by an external test package.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// An identity that does not load still seals the configuration disk: the
|
||||
// guest key on it is erased before the agent refuses to start.
|
||||
func TestConfigDiskStartup_RefusedLoadErasesDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := filepath.Join(dir, "cfg")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "by_name"), 0o700))
|
||||
device := filepath.Join(dir, "vdb")
|
||||
require.NoError(t, os.WriteFile(device, bytes.Repeat([]byte("guest-key"), 512), 0o600))
|
||||
|
||||
_, _, code, err := configDiskStartup(root, device).connect(t.Context())
|
||||
require.Error(t, err)
|
||||
require.Equal(t, exitRefused, code)
|
||||
got, rerr := os.ReadFile(device)
|
||||
require.NoError(t, rerr)
|
||||
require.Len(t, got, 9*512)
|
||||
require.Equal(t, make([]byte, len(got)), got, "configuration disk erased")
|
||||
}
|
||||
|
||||
// A disk that cannot be erased fails closed: the agent refuses, and the
|
||||
// extracted tree is kept so the seal check sees it.
|
||||
func TestConfigDiskStartup_UnerasableDiskRefuses(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := filepath.Join(dir, "cfg")
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "by_name"), 0o700))
|
||||
|
||||
_, _, code, err := configDiskStartup(root, filepath.Join(dir, "missing")).connect(t.Context())
|
||||
require.ErrorContains(t, err, "erase configuration disk")
|
||||
require.Equal(t, exitRefused, code)
|
||||
require.DirExists(t, root)
|
||||
}
|
||||
@@ -28,9 +28,6 @@ Its encoded frame is at most the base64 length of 6 MiB plus 64 KiB of metadata
|
||||
The credential travels as its own frame of at most 8 KiB, and the reply has the same payload and frame bounds as the request.
|
||||
The daemon holds at most two connections at once, from accept to close, and leaves the rest unaccepted in the listen backlog.
|
||||
Under TLS 1.3 the guest's handshake can complete before the host refuses its certificate, so the guest treats the connection as accepted only after the host's first message.
|
||||
In the same builds, the function owner moves a created function with stored code from `Pending` to `Active` once this node's daemon serves the pinned image and the cluster configuration has exactly one node; a function with environment variables or a code copy over the test-link bound stays `Pending`, and `StateReason` names the unmet condition.
|
||||
Release builds never leave `Pending`.
|
||||
The Mulga deploy role installs a test-link build with `deploy_lambda_testlink=true`; this profile does not meet Q-199 fail-closed revocation, and its guests take management addresses whose record can expire (mulga-0hsub).
|
||||
|
||||
**Bootstrap material:** the executor passes these fw_cfg entries under `opt/spinifex/lambda/`, and the agent reads them as root and unloads `qemu_fw_cfg` before user code starts.
|
||||
|
||||
@@ -44,8 +41,6 @@ The Mulga deploy role installs a test-link build with `deploy_lambda_testlink=tr
|
||||
|
||||
The guest's own interface settings come from `opt/spinifex/netcfg`, the entry other system VMs use.
|
||||
The agent unloads `qemu_fw_cfg` even when an entry is missing, and refuses to start if the fw_cfg tree is still readable afterwards.
|
||||
On a VMM without fw_cfg (the test-link Firecracker executor), the executor attaches the same entries as a tar archive on the second, writable virtio-blk disk, laid out as `by_name/<entry>/raw`.
|
||||
The runtime image's init extracts it to a root-only tmpfs at `/spinifex-cfg`, and the agent reads the entries there, then overwrites the whole disk with zeros and removes the tree; it refuses to start if the tree is still present.
|
||||
Until the message format exists, the agent authenticates and holds the connection but accepts no host message and starts no user code.
|
||||
|
||||
**Authority:** one environment's attempt only.
|
||||
|
||||
@@ -108,8 +108,6 @@ Three entry points, in the order they run:
|
||||
|
||||
**Its own suite and its own cell, for instancerecovery's reasons plus one.** It takes a node away, so anything sharing the environment reports that outage as its own failure; it needs its own VPC, public subnet and a backend placement no shared fixture describes; and a failure here has to read as "the load balancer did not survive its host" rather than as one more red subtest in a suite about load balancer features.
|
||||
|
||||
**A dead data NIC is the other half, and there the state does change.** `LBSelfHealsADeadDataNIC` disables one data NIC of a running load balancer guest through a second QMP client and runs for an internet-facing NLB and an internal ALB. The backends and an unrelated customer instance in the same subnet are launched more than 21 minutes before the fault, so a repair that reached a customer instance would find one the platform has long stopped expecting to change. A state watch samples `DescribeLoadBalancers` every two seconds and requires the sequence `active`, `active_impaired`, `active` with nothing in between, while the internet-facing run also watches the name continuously and serves traffic through it within five minutes. Afterwards the guest, its interfaces and its address are unchanged but its process is not, the backends and the bystander keep their instance ID, launch time and process (ADR-0003:S7), and the daemon journals exactly one `ELBv2 data-path repair started` line carrying the fields an operator needs. The internal ALB has no client inside the VPC, so it asserts the state sequence and target health rather than traffic through its name.
|
||||
|
||||
### `iam` — everything IAM and STS
|
||||
|
||||
Policy evaluation against live requests rather than unit-level: a deny applying to the next request without a new credential, resource-scoped grants naming one key pair, a user rename needing `iam:UpdateUser` on both the old and the new name, `aws:SourceIp` seeing the real client address, assumed-role sessions carrying role policies, and revocation stopping one key alone.
|
||||
@@ -131,7 +129,7 @@ The client reports on its serial console because the runner has no route into th
|
||||
|
||||
### `eks`, `ecs`, `ecr` — container services
|
||||
|
||||
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, an IRSA web-identity exchange under the stock AWS trust policy (`StringEquals` on the issuer host-path `:sub`/`:aud`, a mismatched `:sub` denied with `AccessDenied`, a `StringLike` `:sub` granted), and an IRSA pod whose spec names only a ServiceAccount with the role-arn annotation, where the pod-identity webhook injects the token volume, `AWS_*` environment, STS endpoint and CA bundle and the pod assumes the role, while a pod of an unannotated ServiceAccount is left untouched. The unmodified aws-load-balancer-controller chart 1.11.0 installs with the values an AWS user passes plus the gateway endpoint override, the Shield and WAF switches and the single-subnet feature gate, and reconciles an Ingress into an active ALB. Worker IMDS is covered too: a managed nodegroup worker reports IMDSv2 required with hop limit 2, a pod with no IRSA resolves to the node role through IMDS, and a nodegroup built from a launch template takes the template's metadata options. The private-endpoint NIC of the cluster's endpoint NLB is disabled through QMP and every worker must be Ready again within ten minutes while the cluster stays `ACTIVE` and the NLB keeps its guest instance. Control-plane isolation is covered as well: no control-plane host registers as a Node, a DaemonSet tolerating every taint runs on workers only, metrics-server is Ready, and logs work for every kube-system pod. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
|
||||
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, an IRSA web-identity exchange under the stock AWS trust policy (`StringEquals` on the issuer host-path `:sub`/`:aud`, a mismatched `:sub` denied with `AccessDenied`, a `StringLike` `:sub` granted), and an IRSA pod whose spec names only a ServiceAccount with the role-arn annotation, where the pod-identity webhook injects the token volume, `AWS_*` environment, STS endpoint and CA bundle and the pod assumes the role, while a pod of an unannotated ServiceAccount is left untouched. The unmodified aws-load-balancer-controller chart 1.11.0 installs with the values an AWS user passes plus the gateway endpoint override, the Shield and WAF switches and the single-subnet feature gate, and reconciles an Ingress into an active ALB. Worker IMDS is covered too: a managed nodegroup worker reports IMDSv2 required with hop limit 2, a pod with no IRSA resolves to the node role through IMDS, and a nodegroup built from a launch template takes the template's metadata options. Control-plane isolation is covered as well: no control-plane host registers as a Node, a DaemonSet tolerating every taint runs on workers only, metrics-server is Ready, and logs work for every kube-system pod. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
|
||||
|
||||
### `rds` — managed databases
|
||||
|
||||
|
||||
@@ -209,7 +209,6 @@ echo "[build-microvm-image] nginx: binary + stream module present"
|
||||
# virtio*.ko* — virtio bus, net, blk, rng, console, and helpers
|
||||
# *fw_cfg*.ko* — QEMU fw_cfg sysfs driver (delivers boot blobs)
|
||||
# *failover*.ko* — net_failover + failover (virtio_net deps for live migration)
|
||||
# af_packet.ko* — AF_PACKET sockets (lb-agent's per-NIC gateway ARP probe)
|
||||
# All other loadable modules are removed. Drivers compiled into the kernel
|
||||
# (CONFIG_VIRTIO_MMIO=y etc.) are unaffected — they live in vmlinuz, not here.
|
||||
echo "[build-microvm-image] stripping kernel modules to virtio+fw_cfg only..."
|
||||
@@ -223,7 +222,6 @@ for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
|
||||
find "$kernel_dir" -name "virtio*.ko.gz" -o -name "virtio*.ko" \
|
||||
-o -name "*fw_cfg*.ko.gz" -o -name "*fw_cfg*.ko" \
|
||||
-o -name "*failover*.ko.gz" -o -name "*failover*.ko" \
|
||||
-o -name "af_packet.ko.gz" -o -name "af_packet.ko" \
|
||||
2>/dev/null | while read -r mod; do
|
||||
rel="${mod#"$kernel_dir/"}"
|
||||
dest_dir="$tmp_keep/$(dirname "$rel")"
|
||||
@@ -242,15 +240,6 @@ for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
|
||||
done)
|
||||
rm -rf "$tmp_keep"
|
||||
|
||||
# lb-agent's data-path probe needs AF_PACKET; without it every LB would
|
||||
# report a dead NIC. Accept a kernel with CONFIG_PACKET built in.
|
||||
if ! find "$kernel_dir" -name "af_packet.ko*" | grep -q .; then
|
||||
if ! grep -q "CONFIG_PACKET=y" "$KERNEL_CONFIG" 2>/dev/null; then
|
||||
echo "ERROR: af_packet.ko missing from the kept modules — lb-agent's data-path probe cannot open its socket" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Decompress kept modules to plain .ko. Alpine ships .ko.gz, but a host
|
||||
# kmod that lacks gzip support (e.g. Debian trixie's kmod 34) silently
|
||||
# produces an EMPTY modules.dep from compressed modules — the guest's
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -95,18 +94,6 @@ type Agent struct {
|
||||
statsFn func(socketPath string) ([]ServerStatus, error)
|
||||
// For testing: override the active health prober (nginx/NLB).
|
||||
probeFn func(targets []HealthTarget) []ServerStatus
|
||||
|
||||
// Data-path probe: netcfg and sysfs locations, the ARP exchange, the
|
||||
// instance ID lookup and per-NIC miss counters (overridable in tests).
|
||||
netcfgPath string
|
||||
sysNetDir string
|
||||
probeWait time.Duration
|
||||
arpProbeFn arpProbeFn
|
||||
instanceIDFn func() (string, error)
|
||||
instanceID string
|
||||
idSkip int
|
||||
probeMu sync.Mutex
|
||||
probeStates map[string]*nicProbeState
|
||||
}
|
||||
|
||||
// New creates a new LB agent for the given load balancer.
|
||||
@@ -163,12 +150,6 @@ func New(lbID, gatewayURL, accessKey, secretKey, region string) (*Agent, error)
|
||||
reloadNginxFn: reloadNginx,
|
||||
statsFn: queryHAProxyStats,
|
||||
probeFn: probeHealthTargets,
|
||||
netcfgPath: netcfgPath,
|
||||
sysNetDir: sysClassNet,
|
||||
probeWait: probeWait,
|
||||
arpProbeFn: arpProbe,
|
||||
instanceIDFn: fetchInstanceID,
|
||||
probeStates: map[string]*nicProbeState{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -226,7 +207,7 @@ func (a *Agent) tick() {
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := a.sendHeartbeat(servers, a.dataPathEvidence())
|
||||
resp, err := a.sendHeartbeat(servers)
|
||||
if err != nil {
|
||||
// Include the dial target so the serial console disambiguates a
|
||||
// transport failure (never reached AWSGW — "send request: dial ...")
|
||||
@@ -307,7 +288,7 @@ func (a *Agent) enginePaths(engine string) (configPath, pidPath, certDir string,
|
||||
}
|
||||
|
||||
// sendHeartbeat sends a heartbeat with health report to the gateway.
|
||||
func (a *Agent) sendHeartbeat(servers []ServerStatus, ev dataPathEvidence) (*heartbeatResponse, error) {
|
||||
func (a *Agent) sendHeartbeat(servers []ServerStatus) (*heartbeatResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("Action", "LBAgentHeartbeat")
|
||||
params.Set("Version", "2015-12-01")
|
||||
@@ -319,7 +300,6 @@ func (a *Agent) sendHeartbeat(servers []ServerStatus, ev dataPathEvidence) (*hea
|
||||
params.Set("Servers.member."+idx+".Server", s.Server)
|
||||
params.Set("Servers.member."+idx+".Status", s.Status)
|
||||
}
|
||||
ev.addTo(params)
|
||||
|
||||
body, err := a.signedPost(params)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// arpProbe sends one ARP request for gateway out of iface on an AF_PACKET
|
||||
// socket bound to it and waits for the gateway's reply on the same socket.
|
||||
func arpProbe(iface string, mac net.HardwareAddr, src, gateway netip.Addr, wait time.Duration) error {
|
||||
ifi, err := net.InterfaceByName(iface)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interface %s: %w", iface, err)
|
||||
}
|
||||
|
||||
proto := htons(syscall.ETH_P_ARP)
|
||||
fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, int(proto))
|
||||
if err != nil {
|
||||
return fmt.Errorf("packet socket: %w", err)
|
||||
}
|
||||
defer syscall.Close(fd)
|
||||
|
||||
addr := &syscall.SockaddrLinklayer{Protocol: proto, Ifindex: ifi.Index}
|
||||
if err := syscall.Bind(fd, addr); err != nil {
|
||||
return fmt.Errorf("bind %s: %w", iface, err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(wait)
|
||||
if err := syscall.Sendto(fd, buildARPRequest(mac, src, gateway), 0, addr); err != nil {
|
||||
return fmt.Errorf("send ARP request: %w", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 128)
|
||||
for {
|
||||
left := time.Until(deadline)
|
||||
if left <= 0 {
|
||||
return errARPNoReply
|
||||
}
|
||||
tv := syscall.NsecToTimeval(int64(left))
|
||||
if err := syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, &tv); err != nil {
|
||||
return fmt.Errorf("set receive deadline: %w", err)
|
||||
}
|
||||
n, _, err := syscall.Recvfrom(fd, buf, 0)
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EINTR) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("receive ARP reply: %w", err)
|
||||
}
|
||||
if isARPReplyFrom(buf[:n], mac, gateway) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func htons(v uint16) uint16 { return v<<8 | v>>8 }
|
||||
@@ -1,15 +0,0 @@
|
||||
//go:build !linux
|
||||
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
// arpProbe is only meaningful inside the Linux guest.
|
||||
func arpProbe(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error {
|
||||
return errors.New("ARP probe is only supported on linux")
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// netcfgPath is the fw_cfg blob the microVM init script reads to configure NICs.
|
||||
netcfgPath = "/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw"
|
||||
|
||||
sysClassNet = "/sys/class/net"
|
||||
|
||||
// probeFailureThreshold is the number of consecutive missed probes after
|
||||
// which a NIC is reported as failing (about 15 s at the poll interval).
|
||||
probeFailureThreshold = 3
|
||||
|
||||
// arpExchangeWait bounds one ARP exchange. probeWait bounds how long a tick
|
||||
// waits for all probes, so a stuck probe never delays the heartbeat.
|
||||
arpExchangeWait = time.Second
|
||||
probeWait = 1500 * time.Millisecond
|
||||
)
|
||||
|
||||
// nicConfig is one NIC of the netcfg blob that has a gateway to probe.
|
||||
type nicConfig struct {
|
||||
MAC string
|
||||
Addr netip.Addr
|
||||
Gateway netip.Addr
|
||||
}
|
||||
|
||||
// NICReport is the per-NIC data-path evidence carried in the heartbeat.
|
||||
type NICReport struct {
|
||||
MAC string
|
||||
ProbeOK bool
|
||||
ConsecutiveFailures int64
|
||||
RxPackets int64
|
||||
TxPackets int64
|
||||
RxDropped int64
|
||||
TxDropped int64
|
||||
}
|
||||
|
||||
// arpProbeFn sends one ARP request for gateway out of iface and returns nil
|
||||
// when the gateway's reply arrives within timeout.
|
||||
type arpProbeFn func(iface string, mac net.HardwareAddr, src, gateway netip.Addr, timeout time.Duration) error
|
||||
|
||||
// parseNetcfg reads the KEY=value netcfg blob and returns the NICs that have a
|
||||
// gateway, in NIC order. The mgmt NIC carries no gateway and is skipped, as
|
||||
// is any NIC without a usable MAC, address or gateway.
|
||||
func parseNetcfg(blob []byte) []nicConfig {
|
||||
type raw struct{ mac, cidr, gw string }
|
||||
byIndex := map[int]*raw{}
|
||||
|
||||
sc := bufio.NewScanner(bytes.NewReader(blob))
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok || !strings.HasPrefix(key, "NIC") {
|
||||
continue
|
||||
}
|
||||
idxStr, field, ok := strings.Cut(strings.TrimPrefix(key, "NIC"), "_")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
idx, err := strconv.Atoi(idxStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
r := byIndex[idx]
|
||||
if r == nil {
|
||||
r = &raw{}
|
||||
byIndex[idx] = r
|
||||
}
|
||||
switch field {
|
||||
case "MAC":
|
||||
r.mac = strings.ToLower(val)
|
||||
case "CIDR":
|
||||
r.cidr = val
|
||||
case "GW":
|
||||
r.gw = val
|
||||
}
|
||||
}
|
||||
|
||||
idxs := make([]int, 0, len(byIndex))
|
||||
for i := range byIndex {
|
||||
idxs = append(idxs, i)
|
||||
}
|
||||
sort.Ints(idxs)
|
||||
|
||||
var out []nicConfig
|
||||
for _, i := range idxs {
|
||||
r := byIndex[i]
|
||||
if r.mac == "" || r.cidr == "" || r.gw == "" {
|
||||
continue
|
||||
}
|
||||
prefix, err := netip.ParsePrefix(r.cidr)
|
||||
if err != nil || !prefix.Addr().Is4() {
|
||||
continue
|
||||
}
|
||||
gw, err := netip.ParseAddr(r.gw)
|
||||
if err != nil || !gw.Is4() {
|
||||
continue
|
||||
}
|
||||
out = append(out, nicConfig{MAC: r.mac, Addr: prefix.Addr(), Gateway: gw})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ifaceByMAC returns the interface under sysNetDir whose address is mac.
|
||||
func ifaceByMAC(sysNetDir, mac string) (string, error) {
|
||||
entries, err := os.ReadDir(sysNetDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
b, err := os.ReadFile(filepath.Join(sysNetDir, e.Name(), "address"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(string(b)), mac) {
|
||||
return e.Name(), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no interface with MAC %s", mac)
|
||||
}
|
||||
|
||||
// readCounters fills the sysfs statistics of iface into r.
|
||||
func readCounters(sysNetDir, iface string, r *NICReport) {
|
||||
read := func(name string) int64 {
|
||||
b, err := os.ReadFile(filepath.Join(sysNetDir, iface, "statistics", name))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
v, _ := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
|
||||
return v
|
||||
}
|
||||
r.RxPackets = read("rx_packets")
|
||||
r.TxPackets = read("tx_packets")
|
||||
r.RxDropped = read("rx_dropped")
|
||||
r.TxDropped = read("tx_dropped")
|
||||
}
|
||||
|
||||
var errARPNoReply = errors.New("no ARP reply from the gateway")
|
||||
|
||||
// buildARPRequest returns the Ethernet frame asking who has target.
|
||||
func buildARPRequest(mac net.HardwareAddr, src, target netip.Addr) []byte {
|
||||
f := make([]byte, 0, 42)
|
||||
f = append(f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff)
|
||||
f = append(f, mac...)
|
||||
f = binary.BigEndian.AppendUint16(f, 0x0806)
|
||||
f = binary.BigEndian.AppendUint16(f, 1) // Ethernet
|
||||
f = binary.BigEndian.AppendUint16(f, 0x0800) // IPv4
|
||||
f = append(f, 6, 4)
|
||||
f = binary.BigEndian.AppendUint16(f, 1) // request
|
||||
f = append(f, mac...)
|
||||
s := src.As4()
|
||||
f = append(f, s[:]...)
|
||||
f = append(f, 0, 0, 0, 0, 0, 0)
|
||||
t := target.As4()
|
||||
f = append(f, t[:]...)
|
||||
return f
|
||||
}
|
||||
|
||||
// isARPReplyFrom reports whether frame is an ARP reply sent by sender to mac.
|
||||
func isARPReplyFrom(frame []byte, mac net.HardwareAddr, sender netip.Addr) bool {
|
||||
if len(frame) < 42 || binary.BigEndian.Uint16(frame[12:14]) != 0x0806 {
|
||||
return false
|
||||
}
|
||||
if binary.BigEndian.Uint16(frame[20:22]) != 2 || !bytes.Equal(frame[32:38], mac) {
|
||||
return false
|
||||
}
|
||||
s := sender.As4()
|
||||
return bytes.Equal(frame[28:32], s[:])
|
||||
}
|
||||
|
||||
// nicProbeState carries the miss counter of one NIC across ticks. A probe that
|
||||
// has not returned is still in flight, and counts as a miss without starting
|
||||
// another goroutine behind it.
|
||||
type nicProbeState struct {
|
||||
mu sync.Mutex
|
||||
failures int64
|
||||
inflight bool
|
||||
}
|
||||
|
||||
type probeResult struct {
|
||||
ok bool
|
||||
set bool
|
||||
}
|
||||
|
||||
// probeNICs probes every gateway-bearing data NIC once, in parallel, and
|
||||
// returns within probeWait however long a probe blocks.
|
||||
func (a *Agent) probeNICs() []NICReport {
|
||||
blob, err := os.ReadFile(a.netcfgPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
nics := parseNetcfg(blob)
|
||||
if len(nics) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
reports := make([]NICReport, len(nics))
|
||||
results := make([]chan probeResult, len(nics))
|
||||
for i, n := range nics {
|
||||
results[i] = make(chan probeResult, 1)
|
||||
st := a.probeState(n.MAC)
|
||||
|
||||
st.mu.Lock()
|
||||
busy := st.inflight
|
||||
if !busy {
|
||||
st.inflight = true
|
||||
}
|
||||
st.mu.Unlock()
|
||||
if busy {
|
||||
results[i] <- probeResult{set: true}
|
||||
continue
|
||||
}
|
||||
|
||||
go func(n nicConfig, st *nicProbeState, out chan<- probeResult) {
|
||||
ok := a.probeOne(n)
|
||||
st.mu.Lock()
|
||||
st.inflight = false
|
||||
st.mu.Unlock()
|
||||
out <- probeResult{ok: ok, set: true}
|
||||
}(n, st, results[i])
|
||||
}
|
||||
|
||||
deadline := time.NewTimer(a.probeWait)
|
||||
defer deadline.Stop()
|
||||
expired := false
|
||||
for i, n := range nics {
|
||||
var res probeResult
|
||||
if expired {
|
||||
select {
|
||||
case res = <-results[i]:
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
select {
|
||||
case res = <-results[i]:
|
||||
case <-deadline.C:
|
||||
// Out of time: an unfinished probe is a miss for this tick.
|
||||
expired = true
|
||||
}
|
||||
}
|
||||
|
||||
st := a.probeState(n.MAC)
|
||||
st.mu.Lock()
|
||||
if res.ok {
|
||||
st.failures = 0
|
||||
} else {
|
||||
st.failures++
|
||||
}
|
||||
failures := st.failures
|
||||
st.mu.Unlock()
|
||||
|
||||
r := NICReport{
|
||||
MAC: n.MAC,
|
||||
ProbeOK: failures < probeFailureThreshold,
|
||||
ConsecutiveFailures: failures,
|
||||
}
|
||||
if iface, err := ifaceByMAC(a.sysNetDir, n.MAC); err == nil {
|
||||
readCounters(a.sysNetDir, iface, &r)
|
||||
}
|
||||
reports[i] = r
|
||||
}
|
||||
return reports
|
||||
}
|
||||
|
||||
// probeOne runs one ARP exchange for n, false on any failure.
|
||||
func (a *Agent) probeOne(n nicConfig) bool {
|
||||
iface, err := ifaceByMAC(a.sysNetDir, n.MAC)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
hw, err := net.ParseMAC(n.MAC)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return a.arpProbeFn(iface, hw, n.Addr, n.Gateway, arpExchangeWait) == nil
|
||||
}
|
||||
|
||||
func (a *Agent) probeState(mac string) *nicProbeState {
|
||||
a.probeMu.Lock()
|
||||
defer a.probeMu.Unlock()
|
||||
st := a.probeStates[mac]
|
||||
if st == nil {
|
||||
st = &nicProbeState{}
|
||||
a.probeStates[mac] = st
|
||||
}
|
||||
return st
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
testDataMAC = "02:00:00:aa:00:01"
|
||||
testMgmtMAC = "02:00:00:aa:00:02"
|
||||
testNetcfg = "NIC0_MAC=" + testDataMAC + "\nNIC0_CIDR=10.0.1.5/24\nNIC0_GW=10.0.1.1\nNIC0_DEFAULT=1\n" +
|
||||
"NIC1_MAC=" + testMgmtMAC + "\nNIC1_CIDR=10.255.0.9/24\nNIC1_DEFAULT=0\n"
|
||||
)
|
||||
|
||||
// datapathAgent returns a test agent whose netcfg and sysfs live in a temp dir.
|
||||
func datapathAgent(t *testing.T, gwURL, netcfg string) *Agent {
|
||||
t.Helper()
|
||||
a := newTestAgent(t, gwURL)
|
||||
dir := t.TempDir()
|
||||
|
||||
a.netcfgPath = filepath.Join(dir, "netcfg")
|
||||
if err := os.WriteFile(a.netcfgPath, []byte(netcfg), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a.sysNetDir = filepath.Join(dir, "net")
|
||||
for name, mac := range map[string]string{"ens3": testDataMAC, "ens4": testMgmtMAC} {
|
||||
stats := filepath.Join(a.sysNetDir, name, "statistics")
|
||||
if err := os.MkdirAll(stats, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(a.sysNetDir, name, "address"), []byte(mac+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for file, v := range map[string]string{"rx_packets": "100", "tx_packets": "200", "rx_dropped": "3", "tx_dropped": "4"} {
|
||||
if err := os.WriteFile(filepath.Join(stats, file), []byte(v+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.instanceIDFn = func() (string, error) { return "i-0123456789abcdef0", nil }
|
||||
return a
|
||||
}
|
||||
|
||||
func TestParseNetcfg_SkipsMgmtNICAndMissingGateway(t *testing.T) {
|
||||
blob := testNetcfg +
|
||||
"NIC2_MAC=02:00:00:aa:00:03\nNIC2_CIDR=10.0.2.5/24\nNIC2_GW=10.0.2.1\nNIC2_DEFAULT=0\n" +
|
||||
"NIC3_MAC=02:00:00:aa:00:04\nNIC3_DHCP=1\nNIC3_DEFAULT=0\n" +
|
||||
"NIC4_CIDR=10.0.4.5/24\nNIC4_GW=10.0.4.1\n" +
|
||||
"NIC5_MAC=02:00:00:aa:00:06\nNIC5_CIDR=not-a-cidr\nNIC5_GW=10.0.5.1\n"
|
||||
|
||||
got := parseNetcfg([]byte(blob))
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d NICs, want the 2 with a gateway: %+v", len(got), got)
|
||||
}
|
||||
if got[0].MAC != testDataMAC || got[0].Gateway != netip.MustParseAddr("10.0.1.1") || got[0].Addr != netip.MustParseAddr("10.0.1.5") {
|
||||
t.Errorf("first NIC = %+v", got[0])
|
||||
}
|
||||
if got[1].MAC != "02:00:00:aa:00:03" || got[1].Gateway != netip.MustParseAddr("10.0.2.1") {
|
||||
t.Errorf("second NIC = %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNetcfg_OrdersByNICIndexAndLowercasesMAC(t *testing.T) {
|
||||
blob := "NIC10_MAC=02:00:00:AA:00:0A\nNIC10_CIDR=10.0.9.5/24\nNIC10_GW=10.0.9.1\n" +
|
||||
"NIC2_MAC=02:00:00:AA:00:02\nNIC2_CIDR=10.0.2.5/24\nNIC2_GW=10.0.2.1\n"
|
||||
got := parseNetcfg([]byte(blob))
|
||||
if len(got) != 2 || got[0].MAC != "02:00:00:aa:00:02" || got[1].MAC != "02:00:00:aa:00:0a" {
|
||||
t.Fatalf("order or case wrong: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIfaceByMACAndCounters(t *testing.T) {
|
||||
a := datapathAgent(t, "http://unused", testNetcfg)
|
||||
|
||||
iface, err := ifaceByMAC(a.sysNetDir, "02:00:00:AA:00:01")
|
||||
if err != nil || iface != "ens3" {
|
||||
t.Fatalf("iface = %q, err = %v", iface, err)
|
||||
}
|
||||
if _, err := ifaceByMAC(a.sysNetDir, "02:00:00:ff:ff:ff"); err == nil {
|
||||
t.Error("an unknown MAC must not resolve to an interface")
|
||||
}
|
||||
|
||||
var r NICReport
|
||||
readCounters(a.sysNetDir, iface, &r)
|
||||
if r.RxPackets != 100 || r.TxPackets != 200 || r.RxDropped != 3 || r.TxDropped != 4 {
|
||||
t.Errorf("counters = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARPFrames_RequestShapeAndReplyMatching(t *testing.T) {
|
||||
mac, _ := net.ParseMAC(testDataMAC)
|
||||
src, gw := netip.MustParseAddr("10.0.1.5"), netip.MustParseAddr("10.0.1.1")
|
||||
|
||||
req := buildARPRequest(mac, src, gw)
|
||||
if len(req) != 42 || req[12] != 0x08 || req[13] != 0x06 || req[21] != 1 {
|
||||
t.Fatalf("request frame malformed: % x", req)
|
||||
}
|
||||
if string(req[38:42]) != string(gw.AsSlice()) {
|
||||
t.Errorf("target IP not the gateway: % x", req[38:42])
|
||||
}
|
||||
|
||||
reply := make([]byte, 42)
|
||||
copy(reply[0:6], mac)
|
||||
reply[12], reply[13] = 0x08, 0x06
|
||||
reply[21] = 2
|
||||
copy(reply[28:32], gw.AsSlice())
|
||||
copy(reply[32:38], mac)
|
||||
if !isARPReplyFrom(reply, mac, gw) {
|
||||
t.Error("a reply from the gateway to our MAC must match")
|
||||
}
|
||||
if isARPReplyFrom(reply, mac, netip.MustParseAddr("10.0.1.99")) {
|
||||
t.Error("a reply from another host must not match")
|
||||
}
|
||||
reply[21] = 1
|
||||
if isARPReplyFrom(reply, mac, gw) {
|
||||
t.Error("a request must not count as a reply")
|
||||
}
|
||||
if isARPReplyFrom(reply[:20], mac, gw) {
|
||||
t.Error("a short frame must not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeNICs_ProbesOnlyTheGatewayNIC(t *testing.T) {
|
||||
a := datapathAgent(t, "http://unused", testNetcfg)
|
||||
var mu sync.Mutex
|
||||
var probed []string
|
||||
a.arpProbeFn = func(iface string, _ net.HardwareAddr, src, gw netip.Addr, _ time.Duration) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
probed = append(probed, fmt.Sprintf("%s %s>%s", iface, src, gw))
|
||||
return nil
|
||||
}
|
||||
|
||||
reports := a.probeNICs()
|
||||
if len(reports) != 1 || reports[0].MAC != testDataMAC || !reports[0].ProbeOK || reports[0].ConsecutiveFailures != 0 {
|
||||
t.Fatalf("reports = %+v", reports)
|
||||
}
|
||||
if len(probed) != 1 || probed[0] != "ens3 10.0.1.5>10.0.1.1" {
|
||||
t.Errorf("probed = %v, want only the data NIC", probed)
|
||||
}
|
||||
if reports[0].RxPackets != 100 || reports[0].TxDropped != 4 {
|
||||
t.Errorf("counters missing from report: %+v", reports[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeNICs_TwoMissesThenSuccessNeverReportsFailing(t *testing.T) {
|
||||
a := datapathAgent(t, "http://unused", testNetcfg)
|
||||
results := []error{errARPNoReply, errARPNoReply, nil, errARPNoReply, errARPNoReply, nil}
|
||||
var n atomic.Int32
|
||||
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error {
|
||||
return results[n.Add(1)-1]
|
||||
}
|
||||
|
||||
for i, want := range []int64{1, 2, 0, 1, 2, 0} {
|
||||
r := a.probeNICs()
|
||||
if len(r) != 1 || !r[0].ProbeOK || r[0].ConsecutiveFailures != want {
|
||||
t.Fatalf("tick %d: report = %+v, want ok with %d failures", i, r, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeNICs_ThreeMissesReportFailingAndRecoveryClears(t *testing.T) {
|
||||
a := datapathAgent(t, "http://unused", testNetcfg)
|
||||
var fail atomic.Bool
|
||||
fail.Store(true)
|
||||
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error {
|
||||
if fail.Load() {
|
||||
return errARPNoReply
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := 1; i <= 4; i++ {
|
||||
r := a.probeNICs()
|
||||
wantOK := i < 3 // failing from the third consecutive miss
|
||||
if r[0].ProbeOK != wantOK || r[0].ConsecutiveFailures != int64(i) {
|
||||
t.Fatalf("miss %d: report = %+v, want ProbeOK=%v", i, r[0], wantOK)
|
||||
}
|
||||
}
|
||||
|
||||
fail.Store(false)
|
||||
if r := a.probeNICs(); !r[0].ProbeOK || r[0].ConsecutiveFailures != 0 {
|
||||
t.Errorf("a successful probe must clear the failure: %+v", r[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeNICs_BlockedProbeIsBoundedAndCountsAsMiss(t *testing.T) {
|
||||
a := datapathAgent(t, "http://unused", testNetcfg)
|
||||
a.probeWait = 50 * time.Millisecond
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
var calls atomic.Int32
|
||||
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error {
|
||||
calls.Add(1)
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
start := time.Now()
|
||||
r := a.probeNICs()
|
||||
if d := time.Since(start); d > time.Second {
|
||||
t.Fatalf("tick %d waited %s on a stuck probe", i, d)
|
||||
}
|
||||
if len(r) != 1 || r[0].ConsecutiveFailures != int64(i) {
|
||||
t.Fatalf("tick %d: report = %+v, want %d misses", i, r, i)
|
||||
}
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Errorf("a stuck probe was started %d times, want 1 (no goroutine pile-up)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTick_BlockedProbeDoesNotDelayHeartbeatBeyondOneTick(t *testing.T) {
|
||||
var got atomic.Pointer[url.Values]
|
||||
gw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
if r.FormValue("Action") == "LBAgentHeartbeat" {
|
||||
v := r.Form
|
||||
got.Store(&v)
|
||||
}
|
||||
fmt.Fprint(w, `<LBAgentHeartbeatResponse><LBAgentHeartbeatResult><Status>active</Status><ConfigHash>h1</ConfigHash></LBAgentHeartbeatResult></LBAgentHeartbeatResponse>`)
|
||||
}))
|
||||
defer gw.Close()
|
||||
|
||||
a := datapathAgent(t, gw.URL, testNetcfg)
|
||||
a.localConfigHash = "h1"
|
||||
a.probeWait = 50 * time.Millisecond
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error {
|
||||
<-release
|
||||
return nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
a.tick()
|
||||
if d := time.Since(start); d >= pollInterval {
|
||||
t.Fatalf("tick took %s with a stuck probe, want under one %s tick", d, pollInterval)
|
||||
}
|
||||
if got.Load() == nil {
|
||||
t.Fatal("the heartbeat was never sent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeat_EncodesEvidenceWhenPresent(t *testing.T) {
|
||||
var got atomic.Pointer[url.Values]
|
||||
gw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
v := r.Form
|
||||
got.Store(&v)
|
||||
fmt.Fprint(w, `<LBAgentHeartbeatResponse><LBAgentHeartbeatResult><Status>active</Status><ConfigHash>h1</ConfigHash></LBAgentHeartbeatResult></LBAgentHeartbeatResponse>`)
|
||||
}))
|
||||
defer gw.Close()
|
||||
|
||||
a := datapathAgent(t, gw.URL, testNetcfg)
|
||||
a.localConfigHash = "h1"
|
||||
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error { return errARPNoReply }
|
||||
|
||||
a.tick()
|
||||
v := got.Load()
|
||||
if v == nil {
|
||||
t.Fatal("no heartbeat received")
|
||||
}
|
||||
want := map[string]string{
|
||||
"InstanceID": "i-0123456789abcdef0",
|
||||
"NICs.member.1.MAC": testDataMAC,
|
||||
"NICs.member.1.ProbeOK": "true",
|
||||
"NICs.member.1.ConsecutiveFailures": "1",
|
||||
"NICs.member.1.RxPackets": "100",
|
||||
"NICs.member.1.TxPackets": "200",
|
||||
"NICs.member.1.RxDropped": "3",
|
||||
"NICs.member.1.TxDropped": "4",
|
||||
}
|
||||
for k, w := range want {
|
||||
if g := v.Get(k); g != w {
|
||||
t.Errorf("%s = %q, want %q", k, g, w)
|
||||
}
|
||||
}
|
||||
if v.Get("NICs.member.2.MAC") != "" {
|
||||
t.Error("the mgmt NIC must not be reported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeat_OmitsEvidenceWithoutNetcfg(t *testing.T) {
|
||||
var got atomic.Pointer[url.Values]
|
||||
gw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
v := r.Form
|
||||
got.Store(&v)
|
||||
fmt.Fprint(w, `<LBAgentHeartbeatResponse><LBAgentHeartbeatResult><Status>active</Status><ConfigHash>h1</ConfigHash></LBAgentHeartbeatResult></LBAgentHeartbeatResponse>`)
|
||||
}))
|
||||
defer gw.Close()
|
||||
|
||||
a := datapathAgent(t, gw.URL, testNetcfg)
|
||||
a.localConfigHash = "h1"
|
||||
a.netcfgPath = filepath.Join(t.TempDir(), "absent")
|
||||
a.instanceIDFn = func() (string, error) { return "", errors.New("must not be asked without NICs") }
|
||||
|
||||
a.tick()
|
||||
v := got.Load()
|
||||
if v == nil {
|
||||
t.Fatal("no heartbeat received")
|
||||
}
|
||||
for k := range *v {
|
||||
if k == "InstanceID" || len(k) >= 4 && k[:4] == "NICs" {
|
||||
t.Errorf("unexpected evidence parameter %s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataPathEvidence_InstanceIDRetriedUntilKnown(t *testing.T) {
|
||||
a := datapathAgent(t, "http://unused", testNetcfg)
|
||||
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error { return nil }
|
||||
var calls atomic.Int32
|
||||
a.instanceIDFn = func() (string, error) {
|
||||
if calls.Add(1) < 3 {
|
||||
return "", errors.New("metadata not ready")
|
||||
}
|
||||
return "i-late", nil
|
||||
}
|
||||
|
||||
// A failure backs the lookup off for imdsRetryTicks-1 ticks.
|
||||
ticks := func(n int) string {
|
||||
var id string
|
||||
for range n {
|
||||
id = a.dataPathEvidence().InstanceID
|
||||
}
|
||||
return id
|
||||
}
|
||||
if got := ticks(imdsRetryTicks); got != "" || calls.Load() != 1 {
|
||||
t.Fatalf("after %d ticks: id %q, calls %d; want no id and one lookup", imdsRetryTicks, got, calls.Load())
|
||||
}
|
||||
if got := ticks(imdsRetryTicks); got != "" || calls.Load() != 2 {
|
||||
t.Fatalf("second window: id %q, calls %d; want no id and two lookups", got, calls.Load())
|
||||
}
|
||||
if got := ticks(imdsRetryTicks); got != "i-late" || calls.Load() != 3 {
|
||||
t.Fatalf("third window: id %q, calls %d; want i-late and three lookups", got, calls.Load())
|
||||
}
|
||||
ticks(imdsRetryTicks)
|
||||
if calls.Load() != 3 {
|
||||
t.Errorf("metadata asked %d times, want 3 (stop once known)", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTick_BlockedInstanceIDLookupDoesNotDelayHeartbeat(t *testing.T) {
|
||||
var got atomic.Pointer[url.Values]
|
||||
gw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
if r.FormValue("Action") == "LBAgentHeartbeat" {
|
||||
v := r.Form
|
||||
got.Store(&v)
|
||||
}
|
||||
fmt.Fprint(w, `<LBAgentHeartbeatResponse><LBAgentHeartbeatResult><Status>active</Status><ConfigHash>h1</ConfigHash></LBAgentHeartbeatResult></LBAgentHeartbeatResponse>`)
|
||||
}))
|
||||
defer gw.Close()
|
||||
|
||||
a := datapathAgent(t, gw.URL, testNetcfg)
|
||||
a.localConfigHash = "h1"
|
||||
a.arpProbeFn = func(string, net.HardwareAddr, netip.Addr, netip.Addr, time.Duration) error { return nil }
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
var calls atomic.Int32
|
||||
a.instanceIDFn = func() (string, error) {
|
||||
calls.Add(1)
|
||||
<-release
|
||||
return "", errors.New("unreachable")
|
||||
}
|
||||
|
||||
for i := range 3 {
|
||||
start := time.Now()
|
||||
a.tick()
|
||||
if d := time.Since(start); d >= pollInterval {
|
||||
t.Fatalf("tick %d took %s with IMDS blocked, want under one %s tick", i, d, pollInterval)
|
||||
}
|
||||
}
|
||||
v := got.Load()
|
||||
if v == nil {
|
||||
t.Fatal("the heartbeat was never sent")
|
||||
}
|
||||
if v.Get("InstanceID") != "" {
|
||||
t.Error("InstanceID must be omitted while the lookup fails")
|
||||
}
|
||||
if v.Get("NICs.member.1.MAC") == "" {
|
||||
t.Error("NIC evidence must still be sent")
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("blocked lookup started %d times in 3 ticks, want 1 (backoff)", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchInstanceID_SharesOneDeadline(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(3 * imdsWait)
|
||||
}))
|
||||
defer srv.Close()
|
||||
old := imdsBase
|
||||
imdsBase = srv.URL + "/latest"
|
||||
defer func() { imdsBase = old }()
|
||||
|
||||
start := time.Now()
|
||||
if _, err := fetchInstanceID(); err == nil {
|
||||
t.Fatal("a hung metadata service must fail the lookup")
|
||||
}
|
||||
if d := time.Since(start); d > imdsWait+imdsWait/2 {
|
||||
t.Fatalf("lookup took %s, want about one %s deadline", d, imdsWait)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARPProbe_NoResponderFailsClosed(t *testing.T) {
|
||||
mac, _ := net.ParseMAC(testDataMAC)
|
||||
// Nobody answers an ARP request sent on loopback, so a probe that returned
|
||||
// nil here would mask a dead data path. It fails either on the socket (no
|
||||
// CAP_NET_RAW) or on the missing reply.
|
||||
err := arpProbe("lo", mac, netip.MustParseAddr("10.9.9.2"), netip.MustParseAddr("10.9.9.1"), 100*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("a probe with no responder must fail")
|
||||
}
|
||||
if err := arpProbe("no-such-iface0", mac, netip.MustParseAddr("10.9.9.2"), netip.MustParseAddr("10.9.9.1"), 100*time.Millisecond); err == nil {
|
||||
t.Fatal("a probe on a missing interface must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchInstanceID(t *testing.T) {
|
||||
serve := func(tokenStatus, idStatus int, wantToken string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPut && r.URL.Path == "/latest/api/token":
|
||||
w.WriteHeader(tokenStatus)
|
||||
fmt.Fprint(w, "tok123")
|
||||
case r.URL.Path == "/latest/meta-data/instance-id":
|
||||
if got := r.Header.Get("X-Aws-Ec2-Metadata-Token"); got != wantToken {
|
||||
t.Errorf("token header = %q, want %q", got, wantToken)
|
||||
}
|
||||
w.WriteHeader(idStatus)
|
||||
fmt.Fprint(w, " i-abc123 \n")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
saved := imdsBase
|
||||
defer func() { imdsBase = saved }()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
tokenStatus, idStatus int
|
||||
wantToken string
|
||||
wantID string
|
||||
wantErr bool
|
||||
}{
|
||||
{"token then id", http.StatusOK, http.StatusOK, "tok123", "i-abc123", false},
|
||||
{"token refused falls back to v1", http.StatusNotFound, http.StatusOK, "", "i-abc123", false},
|
||||
{"id endpoint error", http.StatusOK, http.StatusServiceUnavailable, "tok123", "", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := serve(tc.tokenStatus, tc.idStatus, tc.wantToken)
|
||||
defer srv.Close()
|
||||
imdsBase = srv.URL + "/latest"
|
||||
id, err := fetchInstanceID()
|
||||
if (err != nil) != tc.wantErr || id != tc.wantID {
|
||||
t.Fatalf("id = %q, err = %v", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
imdsBase = "http://127.0.0.1:1/latest"
|
||||
if _, err := fetchInstanceID(); err == nil {
|
||||
t.Error("an unreachable metadata service must fail")
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
imdsWait = time.Second
|
||||
imdsRetryTicks = 6
|
||||
)
|
||||
|
||||
// imdsBase is the in-VM metadata endpoint (a variable so tests can point it at a fake).
|
||||
var imdsBase = "http://169.254.169.254/latest"
|
||||
|
||||
// dataPathEvidence is the optional part of a heartbeat: which VM the agent runs
|
||||
// in and what each data NIC's gateway probe saw. An empty value adds nothing,
|
||||
// which is what an agent without a netcfg blob sends.
|
||||
type dataPathEvidence struct {
|
||||
InstanceID string
|
||||
NICs []NICReport
|
||||
}
|
||||
|
||||
// addTo encodes the evidence as additional heartbeat query parameters.
|
||||
func (e dataPathEvidence) addTo(params url.Values) {
|
||||
if e.InstanceID != "" {
|
||||
params.Set("InstanceID", e.InstanceID)
|
||||
}
|
||||
for i, n := range e.NICs {
|
||||
p := "NICs.member." + strconv.Itoa(i+1) + "."
|
||||
params.Set(p+"MAC", n.MAC)
|
||||
params.Set(p+"ProbeOK", strconv.FormatBool(n.ProbeOK))
|
||||
params.Set(p+"ConsecutiveFailures", strconv.FormatInt(n.ConsecutiveFailures, 10))
|
||||
params.Set(p+"RxPackets", strconv.FormatInt(n.RxPackets, 10))
|
||||
params.Set(p+"TxPackets", strconv.FormatInt(n.TxPackets, 10))
|
||||
params.Set(p+"RxDropped", strconv.FormatInt(n.RxDropped, 10))
|
||||
params.Set(p+"TxDropped", strconv.FormatInt(n.TxDropped, 10))
|
||||
}
|
||||
}
|
||||
|
||||
// dataPathEvidence gathers the probe results and instance ID for one heartbeat.
|
||||
// Neither step can fail the heartbeat: missing evidence is simply omitted.
|
||||
func (a *Agent) dataPathEvidence() dataPathEvidence {
|
||||
nics := a.probeNICs()
|
||||
if len(nics) == 0 {
|
||||
return dataPathEvidence{}
|
||||
}
|
||||
if a.instanceID == "" {
|
||||
a.lookupInstanceID()
|
||||
}
|
||||
return dataPathEvidence{InstanceID: a.instanceID, NICs: nics}
|
||||
}
|
||||
|
||||
// lookupInstanceID asks for the instance ID without ever holding up a tick: the
|
||||
// call is bounded by imdsWait and, after a failure, retried only every
|
||||
// imdsRetryTicks ticks.
|
||||
func (a *Agent) lookupInstanceID() {
|
||||
if a.idSkip > 0 {
|
||||
a.idSkip--
|
||||
return
|
||||
}
|
||||
type result struct {
|
||||
id string
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
id, err := a.instanceIDFn()
|
||||
ch <- result{id, err}
|
||||
}()
|
||||
timer := time.NewTimer(imdsWait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case r := <-ch:
|
||||
if r.err == nil && r.id != "" {
|
||||
a.instanceID = r.id
|
||||
return
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
a.idSkip = imdsRetryTicks - 1
|
||||
}
|
||||
|
||||
// fetchInstanceID reads the instance ID from the in-VM metadata service. The
|
||||
// token request and the read share one deadline.
|
||||
func fetchInstanceID() (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), imdsWait)
|
||||
defer cancel()
|
||||
c := &http.Client{}
|
||||
|
||||
var token string
|
||||
if req, err := http.NewRequestWithContext(ctx, http.MethodPut, imdsBase+"/api/token", nil); err == nil {
|
||||
req.Header.Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "60")
|
||||
if resp, err := c.Do(req); err == nil {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
token = strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imdsBase+"/meta-data/instance-id", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("X-Aws-Ec2-Metadata-Token", token)
|
||||
}
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("instance-id: status %d", resp.StatusCode)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id := strings.TrimSpace(string(b))
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("instance-id: empty")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package hostlink
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// On a VMM without fw_cfg the executor attaches the same entries as a tar
|
||||
// archive on a second disk, which the runtime image's init extracts here in
|
||||
// the fw_cfg sysfs layout, so the fw_cfg readers work unchanged.
|
||||
const (
|
||||
ConfigDiskRoot = "/spinifex-cfg"
|
||||
ConfigDiskDir = ConfigDiskRoot + "/by_name/" + lambdav1.FwCfgPrefix
|
||||
ConfigDiskDevice = "/dev/vdb"
|
||||
)
|
||||
|
||||
// zeroChunk bounds each write while erasing the configuration disk.
|
||||
const zeroChunk = 64 << 10
|
||||
|
||||
// unmount is a var so tests without privilege can stand in for the tmpfs.
|
||||
var unmount = unix.Unmount
|
||||
|
||||
// SealConfigDisk overwrites the whole configuration disk with zeros, then
|
||||
// removes the extracted tree. The tree stays if the disk cannot be erased, so
|
||||
// SealFwCfg's check on root fails closed.
|
||||
func SealConfigDisk(device, root string) error {
|
||||
if err := zeroDevice(device); err != nil {
|
||||
return fmt.Errorf("hostlink: erase configuration disk: %w", err)
|
||||
}
|
||||
if err := unmount(root, 0); err != nil && !errors.Is(err, unix.EINVAL) {
|
||||
return fmt.Errorf("hostlink: unmount %s: %w", root, err)
|
||||
}
|
||||
if err := os.RemoveAll(root); err != nil {
|
||||
return fmt.Errorf("hostlink: remove %s: %w", root, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func zeroDevice(device string) error {
|
||||
f, err := os.OpenFile(device, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
size, err := f.Seek(0, io.SeekEnd)
|
||||
if err == nil {
|
||||
_, err = f.Seek(0, io.SeekStart)
|
||||
}
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
zeros := make([]byte, zeroChunk)
|
||||
for left := size; left > 0; {
|
||||
n := min(left, int64(len(zeros)))
|
||||
if _, err := f.Write(zeros[:n]); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
left -= n
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
//test:in-package reaches the unexported configDisk and fwCfgTree readers to seal and read a real archive
|
||||
|
||||
package hostlink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// configDisk writes a fake disk holding secret and an extracted tree; the
|
||||
// stand-in unmount reports the tree is not a mount, as on a plain directory.
|
||||
func configDisk(t *testing.T, secret []byte) (device, root string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
device = filepath.Join(dir, "vdb")
|
||||
require.NoError(t, os.WriteFile(device, append(bytes.Repeat([]byte{0xa5}, 3*zeroChunk+7), secret...), 0o600))
|
||||
root = fwCfgTree(t)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "by_name", "key"), secret, 0o600))
|
||||
old := unmount
|
||||
unmount = func(string, int) error { return unix.EINVAL }
|
||||
t.Cleanup(func() { unmount = old })
|
||||
return device, root
|
||||
}
|
||||
|
||||
func TestSealConfigDisk(t *testing.T) {
|
||||
secret := []byte("guest-key-material")
|
||||
|
||||
t.Run("erases the disk and removes the tree", func(t *testing.T) {
|
||||
device, root := configDisk(t, secret)
|
||||
before, err := os.Stat(device)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, SealFwCfg(root, func() error { return SealConfigDisk(device, root) }))
|
||||
|
||||
disk, err := os.ReadFile(device)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, disk, int(before.Size()))
|
||||
assert.Equal(t, make([]byte, len(disk)), disk, "configuration disk not fully erased")
|
||||
_, err = os.Stat(root)
|
||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||
})
|
||||
t.Run("an unwritable disk keeps the tree and fails closed", func(t *testing.T) {
|
||||
device, root := configDisk(t, secret)
|
||||
require.NoError(t, os.Chmod(device, 0o400))
|
||||
err := SealFwCfg(root, func() error { return SealConfigDisk(device, root) })
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "still readable")
|
||||
_, err = os.Stat(root)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
t.Run("a failed unmount keeps the tree", func(t *testing.T) {
|
||||
device, root := configDisk(t, secret)
|
||||
unmount = func(string, int) error { return unix.EBUSY }
|
||||
require.Error(t, SealFwCfg(root, func() error { return SealConfigDisk(device, root) }))
|
||||
_, err := os.Stat(root)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -57,7 +57,7 @@ const (
|
||||
BootAMI BootMode = "ami"
|
||||
)
|
||||
|
||||
// SystemInstanceLauncher launches, reboots and terminates system-managed VMs.
|
||||
// SystemInstanceLauncher launches and terminates system-managed VMs.
|
||||
// Defined here to avoid a circular import between elbv2/eks and daemon.
|
||||
type SystemInstanceLauncher interface {
|
||||
// LaunchSystemInstance creates and starts a system-managed VM, returning its ID and private IP.
|
||||
@@ -65,10 +65,6 @@ type SystemInstanceLauncher interface {
|
||||
|
||||
// TerminateSystemInstance stops and cleans up a system-managed VM.
|
||||
TerminateSystemInstance(instanceID string) error
|
||||
|
||||
// RebootSystemInstance restarts a system-managed load balancer VM into a
|
||||
// fresh QEMU on its owning node, keeping its ID, ENIs and addresses.
|
||||
RebootSystemInstance(instanceID string) error
|
||||
}
|
||||
|
||||
// SystemInstanceInput describes a system VM to launch.
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/query"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLBAgentHeartbeatInput_OldAgentDecodesUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Exactly the parameters an agent without the data-path probe sends.
|
||||
params := map[string]string{
|
||||
"Action": "LBAgentHeartbeat",
|
||||
"Version": "2015-12-01",
|
||||
"LBID": "lb-abc123",
|
||||
"Servers.member.1.Backend": "bk_tg1",
|
||||
"Servers.member.1.Server": "srv_i-web1",
|
||||
"Servers.member.1.Status": "UP",
|
||||
"Servers.member.2.Backend": "bk_tg1",
|
||||
"Servers.member.2.Server": "srv_i-web2",
|
||||
"Servers.member.2.Status": "DOWN",
|
||||
}
|
||||
|
||||
var in LBAgentHeartbeatInput
|
||||
require.NoError(t, query.QueryParamsToStruct(params, &in))
|
||||
|
||||
require.NotNil(t, in.LBID)
|
||||
assert.Equal(t, "lb-abc123", *in.LBID)
|
||||
require.Len(t, in.Servers, 2)
|
||||
assert.Equal(t, "DOWN", *in.Servers[1].Status)
|
||||
assert.Nil(t, in.InstanceID, "an old agent reports no instance ID")
|
||||
assert.Empty(t, in.NICs, "an old agent reports no NICs")
|
||||
assert.Len(t, in.toHealthReport().Servers, 2)
|
||||
}
|
||||
|
||||
func TestLBAgentHeartbeatInput_DecodesDataPathEvidence(t *testing.T) {
|
||||
t.Parallel()
|
||||
params := map[string]string{
|
||||
"LBID": "lb-abc123",
|
||||
"InstanceID": "i-0123456789abcdef0",
|
||||
"NICs.member.1.MAC": "02:00:00:aa:00:01",
|
||||
"NICs.member.1.ProbeOK": "false",
|
||||
"NICs.member.1.ConsecutiveFailures": "4",
|
||||
"NICs.member.1.RxPackets": "100",
|
||||
"NICs.member.1.TxPackets": "200",
|
||||
"NICs.member.1.RxDropped": "3",
|
||||
"NICs.member.1.TxDropped": "9",
|
||||
"NICs.member.2.MAC": "02:00:00:aa:00:03",
|
||||
"NICs.member.2.ProbeOK": "true",
|
||||
"NICs.member.2.ConsecutiveFailures": "0",
|
||||
}
|
||||
|
||||
var in LBAgentHeartbeatInput
|
||||
require.NoError(t, query.QueryParamsToStruct(params, &in))
|
||||
|
||||
require.NotNil(t, in.InstanceID)
|
||||
assert.Equal(t, "i-0123456789abcdef0", *in.InstanceID)
|
||||
require.Len(t, in.NICs, 2)
|
||||
|
||||
n := in.NICs[0]
|
||||
assert.Equal(t, "02:00:00:aa:00:01", *n.MAC)
|
||||
assert.False(t, *n.ProbeOK)
|
||||
assert.EqualValues(t, 4, *n.ConsecutiveFailures)
|
||||
assert.EqualValues(t, 100, *n.RxPackets)
|
||||
assert.EqualValues(t, 200, *n.TxPackets)
|
||||
assert.EqualValues(t, 3, *n.RxDropped)
|
||||
assert.EqualValues(t, 9, *n.TxDropped)
|
||||
|
||||
assert.True(t, *in.NICs[1].ProbeOK)
|
||||
assert.Nil(t, in.NICs[1].RxPackets, "an omitted counter stays unset")
|
||||
}
|
||||
@@ -7,25 +7,9 @@ import (
|
||||
|
||||
// LBAgentHeartbeatInput is sent by the LB agent on each heartbeat tick,
|
||||
// including its health report so the daemon can update target health.
|
||||
//
|
||||
// InstanceID and NICs are optional: an older agent sends neither.
|
||||
type LBAgentHeartbeatInput struct {
|
||||
LBID *string `locationName:"LBID" type:"string"`
|
||||
Servers []*LBAgentServerStatus `locationName:"Servers" type:"list"`
|
||||
InstanceID *string `locationName:"InstanceID" type:"string"`
|
||||
NICs []*LBAgentNICStatus `locationName:"NICs" type:"list"`
|
||||
}
|
||||
|
||||
// LBAgentNICStatus is the gateway probe result and interface counters of one
|
||||
// data NIC, as reported by the LB agent.
|
||||
type LBAgentNICStatus struct {
|
||||
MAC *string `locationName:"MAC" type:"string"`
|
||||
ProbeOK *bool `locationName:"ProbeOK" type:"boolean"`
|
||||
ConsecutiveFailures *int64 `locationName:"ConsecutiveFailures" type:"long"`
|
||||
RxPackets *int64 `locationName:"RxPackets" type:"long"`
|
||||
TxPackets *int64 `locationName:"TxPackets" type:"long"`
|
||||
RxDropped *int64 `locationName:"RxDropped" type:"long"`
|
||||
TxDropped *int64 `locationName:"TxDropped" type:"long"`
|
||||
LBID *string `locationName:"LBID" type:"string"`
|
||||
Servers []*LBAgentServerStatus `locationName:"Servers" type:"list"`
|
||||
}
|
||||
|
||||
// LBAgentServerStatus represents a single backend server's health status as reported by the LB agent.
|
||||
|
||||
@@ -14,9 +14,6 @@ type mockSystemInstanceLauncher struct {
|
||||
launchErr error
|
||||
terminateErr error
|
||||
terminateDone chan struct{} // closed after TerminateSystemInstance completes
|
||||
onTerminate func(instanceID string)
|
||||
rebootCalls []string
|
||||
rebootErr error
|
||||
}
|
||||
|
||||
func (m *mockSystemInstanceLauncher) LaunchSystemInstance(input *SystemInstanceInput) (*SystemInstanceOutput, error) {
|
||||
@@ -28,22 +25,12 @@ func (m *mockSystemInstanceLauncher) TerminateSystemInstance(instanceID string)
|
||||
m.mu.Lock()
|
||||
m.terminateCalls = append(m.terminateCalls, instanceID)
|
||||
m.mu.Unlock()
|
||||
if m.onTerminate != nil {
|
||||
m.onTerminate(instanceID)
|
||||
}
|
||||
if m.terminateDone != nil {
|
||||
close(m.terminateDone)
|
||||
}
|
||||
return m.terminateErr
|
||||
}
|
||||
|
||||
func (m *mockSystemInstanceLauncher) RebootSystemInstance(instanceID string) error {
|
||||
m.mu.Lock()
|
||||
m.rebootCalls = append(m.rebootCalls, instanceID)
|
||||
m.mu.Unlock()
|
||||
return m.rebootErr
|
||||
}
|
||||
|
||||
// waitTerminate blocks until TerminateSystemInstance has been called.
|
||||
func (m *mockSystemInstanceLauncher) waitTerminate() {
|
||||
if m.terminateDone != nil {
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLBRepair_AnotherReservationWinsTheFirstCAS(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newRepairEnv(t)
|
||||
lb := e.seed("lbr20", true)
|
||||
cands := e.candidates()
|
||||
require.Len(t, cands, 1)
|
||||
require.True(t, e.rec.eligible(context.Background(), cands[0], e.clock))
|
||||
|
||||
_, err := e.svc.store.UpdateLBHealth(context.Background(), lb.LoadBalancerID, func(rec *LBDataPathRecord) (bool, error) {
|
||||
rec.Repairs = append(rec.Repairs, LBRepair{At: e.clock, InstanceID: lb.InstanceID, Outcome: RepairReserved})
|
||||
return true, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, e.rec.repair(context.Background(), cands[0]))
|
||||
assert.Empty(t, e.reboots())
|
||||
assert.Len(t, e.health(lb.LoadBalancerID).Repairs, 1, "the losing reservation must not add an entry")
|
||||
}
|
||||
|
||||
func TestLBRepair_LeaseLostAfterReservationKeepsItCountedAndDoesNotDispatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newRepairEnv(t)
|
||||
e.seed("lbr21", true)
|
||||
cands := e.candidates()
|
||||
require.Len(t, cands, 1)
|
||||
|
||||
calls := 0
|
||||
e.rec.isLeader = func() bool {
|
||||
calls++
|
||||
return calls == 1
|
||||
}
|
||||
assert.True(t, e.rec.repair(context.Background(), cands[0]), "a written reservation is reported as spent")
|
||||
assert.Empty(t, e.reboots())
|
||||
|
||||
repairs := e.health("lbr21").Repairs
|
||||
require.Len(t, repairs, 1)
|
||||
assert.Equal(t, RepairReserved, repairs[0].Outcome, "the entry stays counted with no outcome")
|
||||
}
|
||||
|
||||
func TestLBRepair_NonLeaderNeverReserves(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newRepairEnv(t)
|
||||
e.seed("lbr22", true)
|
||||
cands := e.candidates()
|
||||
require.Len(t, cands, 1)
|
||||
|
||||
e.rec.isLeader = func() bool { return false }
|
||||
assert.False(t, e.rec.repair(context.Background(), cands[0]))
|
||||
assert.Empty(t, e.health("lbr22").Repairs)
|
||||
}
|
||||
|
||||
func TestLBHealth_FailedResumeThenNewVMHeartbeatsConverge(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
lb := seedHealthLB(t, svc)
|
||||
ctx := context.Background()
|
||||
|
||||
// The old VM's evidence was suspended by SetSubnets, whose resume write then failed.
|
||||
_, err := svc.store.UpdateLBHealth(ctx, lb.LoadBalancerID, func(rec *LBDataPathRecord) (bool, error) {
|
||||
rec.InstanceID = "i-oldvm"
|
||||
rec.Suspended = true
|
||||
rec.SuspendedAt = time.Now().UTC()
|
||||
return true, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, true, 0, 10))
|
||||
rec, err := svc.store.GetLBHealth(ctx, lb.LoadBalancerID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, rec.Suspended, "a heartbeat from the VM the LB names lifts the stale suspension")
|
||||
assert.Equal(t, healthInst, rec.InstanceID)
|
||||
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, false, 3, 11))
|
||||
rec, err = svc.store.GetLBHealth(ctx, lb.LoadBalancerID)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, rec.NICs, healthMACData, "a later NIC failure is recorded and repairable")
|
||||
}
|
||||
|
||||
func TestLBHealth_SuspensionIsNotLiftedByAHeartbeatFromAVMTheLBDoesNotName(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
lb := seedHealthLB(t, svc)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.store.UpdateLBHealth(ctx, lb.LoadBalancerID, func(rec *LBDataPathRecord) (bool, error) {
|
||||
rec.InstanceID = "i-oldvm"
|
||||
rec.Suspended = true
|
||||
rec.SuspendedAt = time.Now().UTC()
|
||||
return true, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
healthBeat(t, svc, aws.String("i-oldvm"), nicStatus(healthMACData, false, 3, 1))
|
||||
rec, err := svc.store.GetLBHealth(ctx, lb.LoadBalancerID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, rec.Suspended)
|
||||
assert.Equal(t, "i-oldvm", rec.InstanceID)
|
||||
}
|
||||
|
||||
func TestLBHealth_SuspensionBackstopLiftsOnlyAfterThirtyMinutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
lb := seedHealthLB(t, svc)
|
||||
ctx := context.Background()
|
||||
|
||||
suspend := func(age time.Duration) {
|
||||
_, err := svc.store.UpdateLBHealth(ctx, lb.LoadBalancerID, func(rec *LBDataPathRecord) (bool, error) {
|
||||
rec.InstanceID = healthInst
|
||||
rec.Suspended = true
|
||||
rec.SuspendedAt = time.Now().UTC().Add(-age)
|
||||
return true, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
suspend(29 * time.Minute)
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, false, 3, 1))
|
||||
rec, err := svc.store.GetLBHealth(ctx, lb.LoadBalancerID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, rec.Suspended, "inside the backstop the suspension holds")
|
||||
|
||||
suspend(31 * time.Minute)
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, false, 3, 2))
|
||||
rec, err = svc.store.GetLBHealth(ctx, lb.LoadBalancerID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, rec.Suspended, "past the backstop the current VM's heartbeat lifts it")
|
||||
assert.Contains(t, rec.NICs, healthMACData)
|
||||
}
|
||||
|
||||
func TestLBHealth_EstablishedFailureSurvivesAnAgentRestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
lb := seedHealthLB(t, svc)
|
||||
ctx := context.Background()
|
||||
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, false, 3, 1))
|
||||
first, err := svc.store.GetLBHealth(ctx, lb.LoadBalancerID)
|
||||
require.NoError(t, err)
|
||||
since := first.NICs[healthMACData].FailingSince
|
||||
|
||||
// A restarted agent reports healthy until it has missed three probes again.
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, true, 1, 2))
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, true, 2, 3))
|
||||
rec, err := svc.store.GetLBHealth(ctx, lb.LoadBalancerID)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, rec.NICs, healthMACData)
|
||||
assert.True(t, since.Equal(rec.NICs[healthMACData].FailingSince), "FailingSince is kept")
|
||||
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, true, 0, 4))
|
||||
rec, err = svc.store.GetLBHealth(ctx, lb.LoadBalancerID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, rec.NICs, "only a clean probe clears the failure")
|
||||
}
|
||||
|
||||
func TestLBHealth_OldVMHeartbeatPausedAcrossPublicationLeavesTheNewBinding(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
lb := seedHealthLB(t, svc)
|
||||
ctx := context.Background()
|
||||
|
||||
// SetSubnets has already published the new VM and rebound the evidence.
|
||||
lb.InstanceID = "i-newvm"
|
||||
require.NoError(t, svc.store.PutLoadBalancer(ctx, lb))
|
||||
require.NoError(t, svc.store.ResumeLBHealth(ctx, lb.LoadBalancerID, "i-newvm"))
|
||||
|
||||
// The old VM's heartbeat read the LB before that and runs now.
|
||||
stale := *lb
|
||||
stale.InstanceID = healthInst
|
||||
svc.recordDataPathEvidence(ctx, &stale, &LBAgentHeartbeatInput{
|
||||
LBID: aws.String(lb.LoadBalancerID),
|
||||
InstanceID: aws.String(healthInst),
|
||||
NICs: []*LBAgentNICStatus{nicStatus(healthMACData, false, 5, 1)},
|
||||
})
|
||||
|
||||
rec, err := svc.store.GetLBHealth(ctx, lb.LoadBalancerID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rec)
|
||||
assert.Equal(t, "i-newvm", rec.InstanceID)
|
||||
assert.Empty(t, rec.NICs, "the old VM's failure must not land on the new binding")
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
const (
|
||||
// KeyPrefixLBHealth holds the data-path evidence of one load balancer. It is
|
||||
// a separate key from the LB record so whole-record writes cannot drop it.
|
||||
KeyPrefixLBHealth = "lbhealth."
|
||||
|
||||
// StateActiveImpaired is what DescribeLoadBalancers reports while a data NIC
|
||||
// of the LB's current VM is failing its gateway probe.
|
||||
StateActiveImpaired = "active_impaired"
|
||||
|
||||
// lbSuspendBackstop is how long a suspension may outlive its mutation before
|
||||
// a heartbeat from the current VM lifts it.
|
||||
lbSuspendBackstop = 30 * time.Minute
|
||||
)
|
||||
|
||||
// Outcomes of a recorded repair. Reserved is written before the repair is
|
||||
// dispatched, so an entry whose outcome was never updated still counts.
|
||||
const (
|
||||
RepairReserved = "reserved"
|
||||
RepairDispatched = "dispatched"
|
||||
RepairRefused = "refused"
|
||||
RepairUnreachable = "unreachable"
|
||||
)
|
||||
|
||||
// LBNICFailure is one data NIC that was failing its gateway probe when the
|
||||
// transition was recorded.
|
||||
type LBNICFailure struct {
|
||||
ENIID string `json:"eni_id,omitempty"`
|
||||
FailingSince time.Time `json:"failing_since"`
|
||||
ConsecutiveFailures int64 `json:"consecutive_failures"`
|
||||
RxPackets int64 `json:"rx_packets"`
|
||||
TxPackets int64 `json:"tx_packets"`
|
||||
RxDropped int64 `json:"rx_dropped"`
|
||||
TxDropped int64 `json:"tx_dropped"`
|
||||
}
|
||||
|
||||
// LBRepair is one repair of an LB's VM and how it ended.
|
||||
type LBRepair struct {
|
||||
At time.Time `json:"at"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
ENIID string `json:"eni_id,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Outcome string `json:"outcome"`
|
||||
}
|
||||
|
||||
// LBDataPathRecord is the data-path evidence of one load balancer VM. NICs holds
|
||||
// only failing NICs, keyed by lower-case MAC; an absent NIC is healthy.
|
||||
type LBDataPathRecord struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Suspended bool `json:"suspended,omitempty"`
|
||||
SuspendedAt time.Time `json:"suspended_at,omitzero"`
|
||||
BoundAt time.Time `json:"bound_at,omitzero"`
|
||||
NICs map[string]*LBNICFailure `json:"nics,omitempty"`
|
||||
Repairs []LBRepair `json:"repairs,omitempty"`
|
||||
}
|
||||
|
||||
// failing reports whether any NIC of the record is failing.
|
||||
func (r *LBDataPathRecord) failing() bool {
|
||||
return r != nil && len(r.NICs) > 0
|
||||
}
|
||||
|
||||
// GetLBHealth returns the evidence record of a load balancer, or nil when none
|
||||
// has been written.
|
||||
func (s *Store) GetLBHealth(ctx context.Context, lbID string) (*LBDataPathRecord, error) {
|
||||
entry, err := s.kv.Get(ctx, KeyPrefixLBHealth+lbID)
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var rec LBDataPathRecord
|
||||
if err := json.Unmarshal(entry.Value(), &rec); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal lb health: %w", err)
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// UpdateLBHealth applies mutate to the evidence record under optimistic
|
||||
// concurrency, creating it when absent. mutate returns false to write nothing.
|
||||
func (s *Store) UpdateLBHealth(ctx context.Context, lbID string, mutate func(*LBDataPathRecord) (bool, error)) (*LBDataPathRecord, error) {
|
||||
return kvutil.Update(ctx, s.kv, KeyPrefixLBHealth+lbID, kvutil.CASConfig{
|
||||
Attempts: targetGroupCASAttempts,
|
||||
CreateIfAbsent: true,
|
||||
Exhausted: func(_ string, attempts int) error {
|
||||
return fmt.Errorf("elbv2: lb health %s contended, CAS exhausted after %d attempts", lbID, attempts)
|
||||
},
|
||||
}, mutate)
|
||||
}
|
||||
|
||||
// DeleteLBHealth removes the evidence record of a load balancer.
|
||||
func (s *Store) DeleteLBHealth(ctx context.Context, lbID string) error {
|
||||
err := s.kv.Delete(ctx, KeyPrefixLBHealth+lbID)
|
||||
if err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SuspendLBHealth marks the load balancer as being torn down or reshaped, so
|
||||
// no repair is admitted against its VM until the mutation finishes.
|
||||
func (s *Store) SuspendLBHealth(ctx context.Context, lbID string) error {
|
||||
_, err := s.UpdateLBHealth(ctx, lbID, func(rec *LBDataPathRecord) (bool, error) {
|
||||
if rec.Suspended {
|
||||
return false, nil
|
||||
}
|
||||
rec.Suspended = true
|
||||
rec.SuspendedAt = time.Now().UTC()
|
||||
return true, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ResumeLBHealth lifts a suspension. A non-empty instanceID also binds the
|
||||
// record to that VM and drops the old VM's failing NICs.
|
||||
func (s *Store) ResumeLBHealth(ctx context.Context, lbID, instanceID string) error {
|
||||
_, err := s.UpdateLBHealth(ctx, lbID, func(rec *LBDataPathRecord) (bool, error) {
|
||||
changed := rec.Suspended
|
||||
rec.Suspended = false
|
||||
rec.SuspendedAt = time.Time{}
|
||||
if instanceID != "" && rec.InstanceID != instanceID {
|
||||
rec.InstanceID = instanceID
|
||||
rec.BoundAt = time.Now().UTC()
|
||||
rec.NICs = nil
|
||||
changed = true
|
||||
}
|
||||
return changed, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// failingNICMACs returns the lower-case MACs that are failing. A probe that
|
||||
// failed starts a failure. An established failure ends only when the latest
|
||||
// probe succeeded, because an agent restarted by a repair reports healthy for
|
||||
// its first misses.
|
||||
func failingNICMACs(nics []*LBAgentNICStatus, established map[string]*LBNICFailure) map[string]*LBAgentNICStatus {
|
||||
out := make(map[string]*LBAgentNICStatus)
|
||||
for _, n := range nics {
|
||||
if n == nil || n.MAC == nil || n.ProbeOK == nil {
|
||||
continue
|
||||
}
|
||||
mac := strings.ToLower(*n.MAC)
|
||||
if !*n.ProbeOK || (established[mac] != nil && aws.Int64Value(n.ConsecutiveFailures) != 0) {
|
||||
out[mac] = n
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recordDataPathEvidence stores the data-path evidence of one heartbeat. It
|
||||
// writes only when the set of failing NICs changes, and ignores evidence that
|
||||
// does not come from the VM the LB record currently names.
|
||||
func (s *ELBv2ServiceImpl) recordDataPathEvidence(ctx context.Context, lb *LoadBalancerRecord, input *LBAgentHeartbeatInput) {
|
||||
if lb.InstanceID == "" || input.InstanceID == nil || *input.InstanceID != lb.InstanceID || len(input.NICs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
rec, err := s.store.GetLBHealth(ctx, lb.LoadBalancerID)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "LBAgentHeartbeat: failed to read data-path evidence", "lbId", lb.LoadBalancerID, "err", err)
|
||||
return
|
||||
}
|
||||
converge := false
|
||||
if rec != nil && rec.Suspended {
|
||||
if !s.suspensionConverges(ctx, lb, rec, *input.InstanceID) {
|
||||
return
|
||||
}
|
||||
converge = true
|
||||
}
|
||||
var known map[string]*LBNICFailure
|
||||
if rec != nil && rec.InstanceID == lb.InstanceID {
|
||||
known = rec.NICs
|
||||
}
|
||||
failing := failingNICMACs(input.NICs, known)
|
||||
if !converge && sameMACs(known, failing) && (len(failing) == 0 || rec != nil) {
|
||||
return
|
||||
}
|
||||
|
||||
eniByMAC := s.resolveLBENIs(ctx, lb, failing)
|
||||
now := time.Now().UTC()
|
||||
_, err = s.store.UpdateLBHealth(ctx, lb.LoadBalancerID, func(cur *LBDataPathRecord) (bool, error) {
|
||||
changed := false
|
||||
if cur.Suspended {
|
||||
if !converge || !suspensionLifts(cur, lb.InstanceID, now) {
|
||||
return false, nil
|
||||
}
|
||||
cur.Suspended = false
|
||||
cur.SuspendedAt = time.Time{}
|
||||
changed = true
|
||||
if cur.InstanceID != lb.InstanceID {
|
||||
cur.InstanceID = lb.InstanceID
|
||||
cur.BoundAt = now
|
||||
cur.NICs = nil
|
||||
}
|
||||
}
|
||||
if cur.InstanceID != "" && cur.InstanceID != lb.InstanceID {
|
||||
return changed, nil
|
||||
}
|
||||
prev := cur.NICs
|
||||
failing := failingNICMACs(input.NICs, prev)
|
||||
if cur.InstanceID == lb.InstanceID && sameMACs(prev, failing) {
|
||||
return changed, nil
|
||||
}
|
||||
next := make(map[string]*LBNICFailure, len(failing))
|
||||
for mac, n := range failing {
|
||||
f := &LBNICFailure{
|
||||
ENIID: eniByMAC[mac],
|
||||
FailingSince: now,
|
||||
ConsecutiveFailures: aws.Int64Value(n.ConsecutiveFailures),
|
||||
RxPackets: aws.Int64Value(n.RxPackets),
|
||||
TxPackets: aws.Int64Value(n.TxPackets),
|
||||
RxDropped: aws.Int64Value(n.RxDropped),
|
||||
TxDropped: aws.Int64Value(n.TxDropped),
|
||||
}
|
||||
if old, ok := prev[mac]; ok {
|
||||
f.FailingSince = old.FailingSince
|
||||
}
|
||||
next[mac] = f
|
||||
}
|
||||
cur.InstanceID = lb.InstanceID
|
||||
cur.NICs = next
|
||||
return true, nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "LBAgentHeartbeat: failed to store data-path evidence", "lbId", lb.LoadBalancerID, "err", err)
|
||||
return
|
||||
}
|
||||
for mac, n := range failing {
|
||||
if _, ok := known[mac]; !ok {
|
||||
slog.WarnContext(ctx, "ELBv2 data-path probe failing",
|
||||
"lbArn", lb.LoadBalancerArn, "lbId", lb.LoadBalancerID, "instanceId", lb.InstanceID,
|
||||
"eniId", eniByMAC[mac], "mac", mac, "consecutive_failures", aws.Int64Value(n.ConsecutiveFailures),
|
||||
"rx_packets", aws.Int64Value(n.RxPackets), "tx_packets", aws.Int64Value(n.TxPackets),
|
||||
"rx_dropped", aws.Int64Value(n.RxDropped), "tx_dropped", aws.Int64Value(n.TxDropped))
|
||||
}
|
||||
}
|
||||
for mac := range known {
|
||||
if _, ok := failing[mac]; !ok {
|
||||
slog.InfoContext(ctx, "ELBv2 data-path probe recovered",
|
||||
"lbArn", lb.LoadBalancerArn, "lbId", lb.LoadBalancerID, "instanceId", lb.InstanceID, "mac", mac)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// suspensionLifts reports whether a suspended record may be lifted by a
|
||||
// heartbeat from instanceID: it is bound to an older VM, or it has outlived the
|
||||
// backstop while bound to the current one.
|
||||
func suspensionLifts(rec *LBDataPathRecord, instanceID string, now time.Time) bool {
|
||||
if rec.InstanceID != "" && rec.InstanceID != instanceID {
|
||||
return true
|
||||
}
|
||||
return !rec.SuspendedAt.IsZero() && now.Sub(rec.SuspendedAt) >= lbSuspendBackstop
|
||||
}
|
||||
|
||||
// suspensionConverges confirms against a fresh read of the LB record that the
|
||||
// VM the heartbeat reports is the one the LB currently names.
|
||||
func (s *ELBv2ServiceImpl) suspensionConverges(ctx context.Context, lb *LoadBalancerRecord, rec *LBDataPathRecord, heartbeatInstance string) bool {
|
||||
if !suspensionLifts(rec, heartbeatInstance, time.Now().UTC()) {
|
||||
return false
|
||||
}
|
||||
fresh, err := s.store.GetLoadBalancer(ctx, lb.LoadBalancerID)
|
||||
return err == nil && fresh != nil && fresh.InstanceID == heartbeatInstance
|
||||
}
|
||||
|
||||
// sameMACs reports whether two failing sets name the same NICs.
|
||||
func sameMACs[A, B any](a map[string]A, b map[string]B) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for mac := range a {
|
||||
if _, ok := b[mac]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// resolveLBENIs maps failing MACs to ENI IDs from the LB's cross-account ENIs
|
||||
// and one describe of its own ENIs. A MAC it cannot resolve maps to nothing.
|
||||
func (s *ELBv2ServiceImpl) resolveLBENIs(ctx context.Context, lb *LoadBalancerRecord, failing map[string]*LBAgentNICStatus) map[string]string {
|
||||
out := make(map[string]string, len(failing))
|
||||
for _, x := range lb.CrossAccountENIs {
|
||||
mac := strings.ToLower(x.ENIMac)
|
||||
if _, ok := failing[mac]; ok {
|
||||
out[mac] = x.ENIID
|
||||
}
|
||||
}
|
||||
if len(out) == len(failing) {
|
||||
return out
|
||||
}
|
||||
for id, eni := range s.describeENIs(ctx, lb.ENIs, lb.AccountID) {
|
||||
mac := strings.ToLower(aws.StringValue(eni.MacAddress))
|
||||
if _, ok := failing[mac]; ok && out[mac] == "" {
|
||||
out[mac] = id
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// impairedByEvidence reports whether the LB's current VM has a failing data NIC.
|
||||
func (s *ELBv2ServiceImpl) impairedByEvidence(ctx context.Context, r *LoadBalancerRecord) bool {
|
||||
if r.State != StateActive || r.InstanceID == "" {
|
||||
return false
|
||||
}
|
||||
rec, err := s.store.GetLBHealth(ctx, r.LoadBalancerID)
|
||||
if err != nil {
|
||||
slog.DebugContext(ctx, "lbRecordToSDK: failed to read data-path evidence", "lbId", r.LoadBalancerID, "err", err)
|
||||
return false
|
||||
}
|
||||
return rec != nil && !rec.Suspended && rec.InstanceID == r.InstanceID && rec.failing()
|
||||
}
|
||||
|
||||
// resumeLBHealth lifts a suspension after a failure that left the VM untouched.
|
||||
func (s *ELBv2ServiceImpl) resumeLBHealth(ctx context.Context, lbID string) {
|
||||
if err := s.store.ResumeLBHealth(ctx, lbID, ""); err != nil {
|
||||
slog.WarnContext(ctx, "failed to lift data-path suspension", "lbId", lbID, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
healthLBID = "lbhealth01"
|
||||
healthInst = "i-lbvm01"
|
||||
healthMACData = "02:00:00:aa:00:01"
|
||||
healthMACX = "02:00:00:aa:00:02"
|
||||
)
|
||||
|
||||
func seedHealthLB(t *testing.T, svc *ELBv2ServiceImpl) *LoadBalancerRecord {
|
||||
t.Helper()
|
||||
lb := &LoadBalancerRecord{
|
||||
LoadBalancerArn: "arn:aws:elasticloadbalancing:us-east-1:" + testAccountID + ":loadbalancer/app/health-lb/" + healthLBID,
|
||||
LoadBalancerID: healthLBID,
|
||||
Name: "health-lb",
|
||||
State: StateActive,
|
||||
InstanceID: healthInst,
|
||||
AccountID: testAccountID,
|
||||
CrossAccountENIs: []ExtraENIInput{
|
||||
{ENIID: "eni-cross01", ENIMac: healthMACX},
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
require.NoError(t, svc.store.PutLoadBalancer(context.Background(), lb))
|
||||
return lb
|
||||
}
|
||||
|
||||
func nicStatus(mac string, ok bool, failures, rx int64) *LBAgentNICStatus {
|
||||
return &LBAgentNICStatus{
|
||||
MAC: aws.String(mac),
|
||||
ProbeOK: aws.Bool(ok),
|
||||
ConsecutiveFailures: aws.Int64(failures),
|
||||
RxPackets: aws.Int64(rx),
|
||||
TxPackets: aws.Int64(rx + 5),
|
||||
RxDropped: aws.Int64(0),
|
||||
TxDropped: aws.Int64(2),
|
||||
}
|
||||
}
|
||||
|
||||
func healthBeat(t *testing.T, svc *ELBv2ServiceImpl, instanceID *string, nics ...*LBAgentNICStatus) {
|
||||
t.Helper()
|
||||
_, err := svc.LBAgentHeartbeat(context.Background(), &LBAgentHeartbeatInput{
|
||||
LBID: aws.String(healthLBID),
|
||||
InstanceID: instanceID,
|
||||
NICs: nics,
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func healthRevision(t *testing.T, svc *ELBv2ServiceImpl) uint64 {
|
||||
t.Helper()
|
||||
entry, err := svc.store.kv.Get(context.Background(), KeyPrefixLBHealth+healthLBID)
|
||||
require.NoError(t, err)
|
||||
return entry.Revision()
|
||||
}
|
||||
|
||||
func TestLBHealth_SteadyHeartbeatsWriteNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
seedHealthLB(t, svc)
|
||||
|
||||
for range 5 {
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, true, 0, 100))
|
||||
}
|
||||
rec, err := svc.store.GetLBHealth(context.Background(), healthLBID)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, rec, "healthy heartbeats must not create an evidence record")
|
||||
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, false, 3, 100))
|
||||
rev := healthRevision(t, svc)
|
||||
for i := range 5 {
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, false, int64(4+i), int64(100+i)))
|
||||
}
|
||||
assert.Equal(t, rev, healthRevision(t, svc), "a NIC that stays failing must not rewrite the record")
|
||||
}
|
||||
|
||||
func TestLBHealth_FailingNICWritesFailingSinceOnceAndRecoveryClears(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
seedHealthLB(t, svc)
|
||||
|
||||
before := time.Now().UTC().Add(-time.Second)
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, false, 3, 777), nicStatus(healthMACX, true, 0, 1))
|
||||
|
||||
rec, err := svc.store.GetLBHealth(context.Background(), healthLBID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rec)
|
||||
assert.Equal(t, healthInst, rec.InstanceID)
|
||||
require.Len(t, rec.NICs, 1)
|
||||
f := rec.NICs[healthMACData]
|
||||
require.NotNil(t, f)
|
||||
assert.True(t, f.FailingSince.After(before))
|
||||
assert.EqualValues(t, 777, f.RxPackets)
|
||||
assert.EqualValues(t, 782, f.TxPackets)
|
||||
assert.EqualValues(t, 2, f.TxDropped)
|
||||
assert.EqualValues(t, 3, f.ConsecutiveFailures)
|
||||
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, true, 0, 900))
|
||||
rec, err = svc.store.GetLBHealth(context.Background(), healthLBID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rec)
|
||||
assert.Empty(t, rec.NICs, "recovery clears the failing NIC")
|
||||
}
|
||||
|
||||
func TestLBHealth_SecondNICFailingKeepsFirstFailingSince(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
seedHealthLB(t, svc)
|
||||
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, false, 3, 1))
|
||||
first, err := svc.store.GetLBHealth(context.Background(), healthLBID)
|
||||
require.NoError(t, err)
|
||||
since := first.NICs[healthMACData].FailingSince
|
||||
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, false, 9, 2), nicStatus(healthMACX, false, 3, 2))
|
||||
rec, err := svc.store.GetLBHealth(context.Background(), healthLBID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rec.NICs, 2)
|
||||
assert.True(t, since.Equal(rec.NICs[healthMACData].FailingSince))
|
||||
assert.Equal(t, "eni-cross01", rec.NICs[healthMACX].ENIID, "a cross-account NIC resolves to its ENI without a describe")
|
||||
}
|
||||
|
||||
func TestLBHealth_WrongOrMissingInstanceIDIsIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
seedHealthLB(t, svc)
|
||||
|
||||
failing := nicStatus(healthMACData, false, 5, 1)
|
||||
healthBeat(t, svc, aws.String("i-someoneelse"), failing)
|
||||
healthBeat(t, svc, aws.String(""), failing)
|
||||
healthBeat(t, svc, nil, failing)
|
||||
|
||||
rec, err := svc.store.GetLBHealth(context.Background(), healthLBID)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, rec, "evidence from another VM, or with no instance ID, must be ignored")
|
||||
}
|
||||
|
||||
func TestLBHealth_WholeRecordPutLeavesEvidenceIntact(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
lb := seedHealthLB(t, svc)
|
||||
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, false, 3, 1))
|
||||
want, err := svc.store.GetLBHealth(context.Background(), healthLBID)
|
||||
require.NoError(t, err)
|
||||
rev := healthRevision(t, svc)
|
||||
|
||||
lb.StateReason = "rewritten by an older daemon"
|
||||
require.NoError(t, svc.store.PutLoadBalancer(context.Background(), lb))
|
||||
|
||||
got, err := svc.store.GetLBHealth(context.Background(), healthLBID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got)
|
||||
assert.Equal(t, rev, healthRevision(t, svc))
|
||||
}
|
||||
|
||||
func TestLBHealth_DeleteLoadBalancerRemovesTheKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
lb := seedHealthLB(t, svc)
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, false, 3, 1))
|
||||
|
||||
_, err := svc.DeleteLoadBalancer(context.Background(), &elbv2.DeleteLoadBalancerInput{
|
||||
LoadBalancerArn: aws.String(lb.LoadBalancerArn),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
rec, err := svc.store.GetLBHealth(context.Background(), healthLBID)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, rec)
|
||||
}
|
||||
|
||||
func TestLBHealth_DeleteLoadBalancerSuspendsBeforeTerminatingTheVM(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
lb := seedHealthLB(t, svc)
|
||||
seen := make(chan bool, 1)
|
||||
svc.InstanceLauncher = &mockSystemInstanceLauncher{
|
||||
onTerminate: func(string) {
|
||||
rec, err := svc.store.GetLBHealth(context.Background(), healthLBID)
|
||||
seen <- err == nil && rec != nil && rec.Suspended
|
||||
},
|
||||
}
|
||||
|
||||
_, err := svc.DeleteLoadBalancer(context.Background(), &elbv2.DeleteLoadBalancerInput{
|
||||
LoadBalancerArn: aws.String(lb.LoadBalancerArn),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case suspended := <-seen:
|
||||
assert.True(t, suspended, "repair must be excluded before the VM is terminated")
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the LB VM was never terminated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLBHealth_SetSubnetsSuspendsFirstAndResumesOnTheNewVM(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, vpcSvc, mock := setupSubnetTestService(t)
|
||||
vid := vpcID(t, vpcSvc)
|
||||
sub1 := getTestSubnetID(t, vpcSvc, vid, "10.0.40.0/24", "us-east-1a")
|
||||
sub2 := getTestSubnetID(t, vpcSvc, vid, "10.0.41.0/24", "us-east-1b")
|
||||
|
||||
out, err := svc.CreateLoadBalancer(context.Background(), &elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("health-sub-lb"),
|
||||
Subnets: []*string{aws.String(sub1)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
svc.WaitLaunches()
|
||||
lbID := aws.StringValue(out.LoadBalancers[0].LoadBalancerArn)
|
||||
rec0, err := svc.store.GetLoadBalancerByArn(context.Background(), lbID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "i-lb", rec0.InstanceID)
|
||||
|
||||
_, err = svc.LBAgentHeartbeat(context.Background(), &LBAgentHeartbeatInput{
|
||||
LBID: aws.String(rec0.LoadBalancerID),
|
||||
InstanceID: aws.String("i-lb"),
|
||||
NICs: []*LBAgentNICStatus{nicStatus(healthMACData, false, 3, 1)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
suspendedAtTerminate := false
|
||||
mock.onTerminate = func(string) {
|
||||
rec, gerr := svc.store.GetLBHealth(context.Background(), rec0.LoadBalancerID)
|
||||
suspendedAtTerminate = gerr == nil && rec != nil && rec.Suspended
|
||||
}
|
||||
mock.launchResult = &SystemInstanceOutput{InstanceID: "i-new", PrivateIP: "10.0.1.6"}
|
||||
|
||||
_, err = svc.SetSubnets(context.Background(), &elbv2.SetSubnetsInput{
|
||||
LoadBalancerArn: aws.String(lbID),
|
||||
Subnets: []*string{aws.String(sub1), aws.String(sub2)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, suspendedAtTerminate, "repair must be excluded before the VM is terminated")
|
||||
|
||||
rec, err := svc.store.GetLBHealth(context.Background(), rec0.LoadBalancerID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rec)
|
||||
assert.False(t, rec.Suspended, "the suspension is lifted once the new VM is published")
|
||||
assert.Equal(t, "i-new", rec.InstanceID)
|
||||
assert.Empty(t, rec.NICs, "the old VM's failing NIC does not carry over")
|
||||
}
|
||||
|
||||
func TestLBHealth_SetSubnetsFailureBeforeTheVMIsTouchedResumes(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, vpcSvc, mock := setupSubnetTestService(t)
|
||||
vid := vpcID(t, vpcSvc)
|
||||
sub1 := getTestSubnetID(t, vpcSvc, vid, "10.0.42.0/24", "us-east-1a")
|
||||
sub2 := getTestSubnetID(t, vpcSvc, vid, "10.0.43.0/24", "us-east-1b")
|
||||
|
||||
out, err := svc.CreateLoadBalancer(context.Background(), &elbv2.CreateLoadBalancerInput{
|
||||
Name: aws.String("health-resume-lb"),
|
||||
Subnets: []*string{aws.String(sub1)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
svc.WaitLaunches()
|
||||
arn := aws.StringValue(out.LoadBalancers[0].LoadBalancerArn)
|
||||
rec0, err := svc.store.GetLoadBalancerByArn(context.Background(), arn)
|
||||
require.NoError(t, err)
|
||||
|
||||
mock.terminateErr = assert.AnError
|
||||
_, err = svc.SetSubnets(context.Background(), &elbv2.SetSubnetsInput{
|
||||
LoadBalancerArn: aws.String(arn),
|
||||
Subnets: []*string{aws.String(sub1), aws.String(sub2)},
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
|
||||
rec, err := svc.store.GetLBHealth(context.Background(), rec0.LoadBalancerID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rec)
|
||||
assert.False(t, rec.Suspended, "a failed reshape that left the VM running must not leave repair excluded")
|
||||
}
|
||||
|
||||
func TestLBHealth_DescribeShowsImpairedOnlyForFailingNICOfCurrentVM(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
lb := seedHealthLB(t, svc)
|
||||
code := func() string {
|
||||
out, err := svc.DescribeLoadBalancers(context.Background(), &elbv2.DescribeLoadBalancersInput{
|
||||
LoadBalancerArns: []*string{aws.String(lb.LoadBalancerArn)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.LoadBalancers, 1)
|
||||
return aws.StringValue(out.LoadBalancers[0].State.Code)
|
||||
}
|
||||
|
||||
assert.Equal(t, StateActive, code())
|
||||
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, false, 3, 1))
|
||||
assert.Equal(t, StateActiveImpaired, code())
|
||||
|
||||
healthBeat(t, svc, aws.String(healthInst), nicStatus(healthMACData, true, 0, 2))
|
||||
assert.Equal(t, StateActive, code(), "active again once the probe recovers")
|
||||
|
||||
// Evidence recorded against a VM the LB no longer names never impairs it.
|
||||
_, err := svc.store.UpdateLBHealth(context.Background(), healthLBID, func(r *LBDataPathRecord) (bool, error) {
|
||||
r.InstanceID = "i-oldvm"
|
||||
r.NICs = map[string]*LBNICFailure{healthMACData: {FailingSince: time.Now().UTC()}}
|
||||
return true, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StateActive, code())
|
||||
|
||||
// A LB that is not active is reported as it is.
|
||||
_, err = svc.store.UpdateLBHealth(context.Background(), healthLBID, func(r *LBDataPathRecord) (bool, error) {
|
||||
r.InstanceID = healthInst
|
||||
return true, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, StateActiveImpaired, code())
|
||||
_, err = svc.store.UpdateLoadBalancer(context.Background(), healthLBID, func(r *LoadBalancerRecord) (bool, error) {
|
||||
r.State = StateFailed
|
||||
return true, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StateFailed, code())
|
||||
}
|
||||
|
||||
// failKeysKV fails key listing once armed, which is what the listener cascade
|
||||
// of DeleteLoadBalancer reads first.
|
||||
type failKeysKV struct {
|
||||
jetstream.KeyValue
|
||||
|
||||
armed atomic.Bool
|
||||
}
|
||||
|
||||
func (k *failKeysKV) Keys(ctx context.Context, opts ...jetstream.WatchOpt) ([]string, error) {
|
||||
if k.armed.Load() {
|
||||
return nil, errors.New("injected key listing failure")
|
||||
}
|
||||
return k.KeyValue.Keys(ctx, opts...)
|
||||
}
|
||||
|
||||
func TestLBHealth_DeleteLoadBalancerListenerCascadeFailureResumes(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
lb := seedHealthLB(t, svc)
|
||||
kv := &failKeysKV{KeyValue: svc.store.kv}
|
||||
svc.store.kv = kv
|
||||
mock := &mockSystemInstanceLauncher{}
|
||||
svc.InstanceLauncher = mock
|
||||
kv.armed.Store(true)
|
||||
|
||||
_, err := svc.DeleteLoadBalancer(context.Background(), &elbv2.DeleteLoadBalancerInput{
|
||||
LoadBalancerArn: aws.String(lb.LoadBalancerArn),
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
kv.armed.Store(false)
|
||||
|
||||
rec, err := svc.store.GetLBHealth(context.Background(), healthLBID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rec)
|
||||
assert.False(t, rec.Suspended, "a delete that failed before touching the VM must not leave repair excluded")
|
||||
assert.Empty(t, mock.terminateCalls)
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/systeminstance"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvlease"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
const (
|
||||
// KVBucketELBv2Leader holds the lease key of the data-path reconciler.
|
||||
KVBucketELBv2Leader = "spinifex-elbv2-leader"
|
||||
KVBucketELBv2LeaderVersion = 1
|
||||
KVBucketELBv2LeaderTTL = 60 * time.Second
|
||||
|
||||
lbRepairLeaderKey = "datapath"
|
||||
lbRepairLeaseRenew = 20 * time.Second
|
||||
lbRepairPassInterval = 30 * time.Second
|
||||
lbRepairMinFailing = 60 * time.Second
|
||||
lbRepairMinVMAge = 5 * time.Minute
|
||||
lbRepairCooldown = 10 * time.Minute
|
||||
lbRepairBudgetWindow = 24 * time.Hour
|
||||
lbRepairBudget = 3
|
||||
lbRepairFleetMinCount = 3
|
||||
|
||||
// A heartbeat that persists only every heartbeatPersistInterval is still
|
||||
// fresh for twice that long.
|
||||
lbRepairHeartbeatMaxAge = 2 * heartbeatPersistInterval
|
||||
)
|
||||
|
||||
// SystemInstanceRebooter is the only capability the repair path has over a VM.
|
||||
type SystemInstanceRebooter interface {
|
||||
RebootSystemInstance(instanceID string) error
|
||||
}
|
||||
|
||||
var _ SystemInstanceRebooter = SystemInstanceLauncher(nil)
|
||||
|
||||
// counts reports whether a repair entry uses up cooldown and budget. Only a
|
||||
// repair no node received is free.
|
||||
func (r LBRepair) counts() bool {
|
||||
return r.Outcome != RepairUnreachable
|
||||
}
|
||||
|
||||
// repairWindow counts the entries inside window ending at now, and returns the
|
||||
// newest of them.
|
||||
func repairWindow(repairs []LBRepair, now time.Time, window time.Duration) (n int, newest time.Time) {
|
||||
for _, r := range repairs {
|
||||
if !r.counts() || now.Sub(r.At) >= window {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
if r.At.After(newest) {
|
||||
newest = r.At
|
||||
}
|
||||
}
|
||||
return n, newest
|
||||
}
|
||||
|
||||
// lbRepairReconciler restarts the VM of a load balancer whose data NIC has been
|
||||
// failing its gateway probe. Only the lease holder acts.
|
||||
type lbRepairReconciler struct {
|
||||
svc *ELBv2ServiceImpl
|
||||
rebooter SystemInstanceRebooter
|
||||
isLeader func() bool
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
budgetReported map[string]bool
|
||||
}
|
||||
|
||||
func newLBRepairReconciler(svc *ELBv2ServiceImpl, rebooter SystemInstanceRebooter, isLeader func() bool) *lbRepairReconciler {
|
||||
return &lbRepairReconciler{
|
||||
svc: svc,
|
||||
rebooter: rebooter,
|
||||
isLeader: isLeader,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
budgetReported: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// StartDataPathReconciler elects a leader among the daemons and runs the repair
|
||||
// pass on it every lbRepairPassInterval until ctx or the service is cancelled.
|
||||
func (s *ELBv2ServiceImpl) StartDataPathReconciler(ctx context.Context, holder string) {
|
||||
if s.InstanceLauncher == nil || s.store == nil || s.store.js == nil {
|
||||
return
|
||||
}
|
||||
lease, err := kvlease.New(kvlease.Config{
|
||||
Name: "elbv2/datapath",
|
||||
Bucket: s.leaderBucket,
|
||||
Key: lbRepairLeaderKey,
|
||||
Holder: holder,
|
||||
TTL: KVBucketELBv2LeaderTTL,
|
||||
Renew: lbRepairLeaseRenew,
|
||||
Retry: lbRepairLeaseRenew,
|
||||
})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "ELBv2 data-path reconciler: lease config invalid", "err", err)
|
||||
return
|
||||
}
|
||||
rec := newLBRepairReconciler(s, s.InstanceLauncher, lease.Held)
|
||||
go lease.Run(ctx)
|
||||
go func() {
|
||||
ticker := time.NewTicker(lbRepairPassInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
rec.pass(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *ELBv2ServiceImpl) leaderBucket(ctx context.Context) (jetstream.KeyValue, error) {
|
||||
return kvlease.OpenBucket(ctx, s.store.js, kvlease.BucketConfig{
|
||||
Name: KVBucketELBv2Leader,
|
||||
TTL: KVBucketELBv2LeaderTTL,
|
||||
Version: KVBucketELBv2LeaderVersion,
|
||||
})
|
||||
}
|
||||
|
||||
// candidate is a load balancer that is failing and may be repaired.
|
||||
type lbRepairCandidate struct {
|
||||
lb *LoadBalancerRecord
|
||||
rec *LBDataPathRecord
|
||||
mac string
|
||||
nic *LBNICFailure
|
||||
since time.Time
|
||||
}
|
||||
|
||||
// pass runs one repair pass. It starts at most one repair.
|
||||
func (r *lbRepairReconciler) pass(ctx context.Context) {
|
||||
if !r.isLeader() {
|
||||
return
|
||||
}
|
||||
lbs, err := r.svc.store.ListLoadBalancersStrict(ctx)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "ELBv2 data-path reconciler: failed to list load balancers", "err", err)
|
||||
return
|
||||
}
|
||||
now := r.now()
|
||||
|
||||
var heartbeating, failing int
|
||||
var cands []lbRepairCandidate
|
||||
live := make(map[string]bool, len(lbs))
|
||||
for _, lb := range lbs {
|
||||
live[lb.LoadBalancerID] = true
|
||||
if lb.State != StateActive || lb.InstanceID == "" || now.Sub(lb.LastHeartbeat) >= lbRepairHeartbeatMaxAge {
|
||||
continue
|
||||
}
|
||||
heartbeating++
|
||||
rec, err := r.svc.store.GetLBHealth(ctx, lb.LoadBalancerID)
|
||||
if err != nil || rec == nil || rec.Suspended || rec.InstanceID != lb.InstanceID || !rec.failing() {
|
||||
continue
|
||||
}
|
||||
failing++
|
||||
c := lbRepairCandidate{lb: lb, rec: rec}
|
||||
for mac, nic := range rec.NICs {
|
||||
if c.nic == nil || nic.FailingSince.Before(c.since) {
|
||||
c.mac, c.nic, c.since = mac, nic, nic.FailingSince
|
||||
}
|
||||
}
|
||||
cands = append(cands, c)
|
||||
}
|
||||
r.forgetBudgetReports(live)
|
||||
|
||||
if failing >= lbRepairFleetMinCount && failing*2 > heartbeating {
|
||||
slog.WarnContext(ctx, "ELBv2 data-path repair suppressed: fleet-wide probe failure",
|
||||
"failing_lbs", failing, "heartbeating_lbs", heartbeating)
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(cands, func(i, j int) bool { return cands[i].since.Before(cands[j].since) })
|
||||
for _, c := range cands {
|
||||
if r.eligible(ctx, c, now) && r.repair(ctx, c) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eligible applies the per-LB conditions that need no write.
|
||||
func (r *lbRepairReconciler) eligible(ctx context.Context, c lbRepairCandidate, now time.Time) bool {
|
||||
if now.Sub(c.since) < lbRepairMinFailing {
|
||||
return false
|
||||
}
|
||||
if now.Sub(vmRunningSince(c.lb, c.rec)) < lbRepairMinVMAge {
|
||||
return false
|
||||
}
|
||||
n, newest := repairWindow(c.rec.Repairs, now, lbRepairBudgetWindow)
|
||||
if n >= lbRepairBudget {
|
||||
r.reportBudgetSpent(ctx, c, n)
|
||||
return false
|
||||
}
|
||||
r.clearBudgetReport(c.lb.LoadBalancerID)
|
||||
return now.Sub(newest) >= lbRepairCooldown || newest.IsZero()
|
||||
}
|
||||
|
||||
// vmRunningSince is when the LB's current VM last came up as far as the control
|
||||
// plane can tell: its activation, a relaunch, or the last repair.
|
||||
func vmRunningSince(lb *LoadBalancerRecord, rec *LBDataPathRecord) time.Time {
|
||||
since := lb.ActiveSince
|
||||
if since.IsZero() {
|
||||
since = lb.CreatedAt
|
||||
}
|
||||
if rec.BoundAt.After(since) {
|
||||
since = rec.BoundAt
|
||||
}
|
||||
for _, e := range rec.Repairs {
|
||||
if e.counts() && e.At.After(since) {
|
||||
since = e.At
|
||||
}
|
||||
}
|
||||
return since
|
||||
}
|
||||
|
||||
// repair reserves the attempt durably, then dispatches it. It reports whether a
|
||||
// repair was started, which ends the pass.
|
||||
func (r *lbRepairReconciler) repair(ctx context.Context, c lbRepairCandidate) bool {
|
||||
if !r.isLeader() {
|
||||
return false
|
||||
}
|
||||
lb, err := r.svc.store.GetLoadBalancer(ctx, c.lb.LoadBalancerID)
|
||||
if err != nil || lb == nil || lb.State != StateActive || lb.InstanceID != c.lb.InstanceID {
|
||||
return false
|
||||
}
|
||||
now := r.now()
|
||||
reason := "data NIC failing gateway probe"
|
||||
var attempt int
|
||||
reserved := false
|
||||
_, err = r.svc.store.UpdateLBHealth(ctx, lb.LoadBalancerID, func(cur *LBDataPathRecord) (bool, error) {
|
||||
// The callback reruns after a conflict, so admission is judged by the run
|
||||
// that wrote the reservation and nothing earlier.
|
||||
reserved, attempt = false, 0
|
||||
nic := cur.NICs[c.mac]
|
||||
if cur.Suspended || cur.InstanceID != lb.InstanceID || nic == nil || now.Sub(nic.FailingSince) < lbRepairMinFailing {
|
||||
return false, nil
|
||||
}
|
||||
n, newest := repairWindow(cur.Repairs, now, lbRepairBudgetWindow)
|
||||
if n >= lbRepairBudget || (!newest.IsZero() && now.Sub(newest) < lbRepairCooldown) {
|
||||
return false, nil
|
||||
}
|
||||
cur.Repairs = append(pruneRepairs(cur.Repairs, now), LBRepair{
|
||||
At: now, InstanceID: lb.InstanceID, ENIID: nic.ENIID, Reason: reason, Outcome: RepairReserved,
|
||||
})
|
||||
attempt = n + 1
|
||||
reserved = true
|
||||
return true, nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "ELBv2 data-path repair: reservation failed", "lbId", lb.LoadBalancerID, "err", err)
|
||||
return false
|
||||
}
|
||||
if !reserved {
|
||||
return false
|
||||
}
|
||||
|
||||
attrs := repairLogAttrs(lb, c, attempt, reason, now)
|
||||
if !r.isLeader() {
|
||||
slog.WarnContext(ctx, "ELBv2 data-path repair not dispatched: leadership lost after reservation", attrs...)
|
||||
return true
|
||||
}
|
||||
slog.WarnContext(ctx, "ELBv2 data-path repair started", attrs...)
|
||||
outcome := r.dispatch(lb.InstanceID)
|
||||
if outcome != "" {
|
||||
r.recordOutcome(ctx, lb.LoadBalancerID, now, lb.InstanceID, outcome)
|
||||
}
|
||||
switch outcome {
|
||||
case RepairDispatched:
|
||||
slog.InfoContext(ctx, "ELBv2 data-path repair dispatched", attrs...)
|
||||
case RepairRefused:
|
||||
slog.ErrorContext(ctx, "ELBv2 data-path repair refused by the owning node", attrs...)
|
||||
case RepairUnreachable:
|
||||
slog.WarnContext(ctx, "ELBv2 data-path repair: owning node unreachable, will retry", attrs...)
|
||||
default:
|
||||
slog.WarnContext(ctx, "ELBv2 data-path repair: outcome unknown, counted", attrs...)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// dispatch asks the owning node to restart exactly the LB's own VM. It returns
|
||||
// an empty outcome when the reply was lost, which leaves the entry reserved.
|
||||
func (r *lbRepairReconciler) dispatch(instanceID string) string {
|
||||
err := r.rebooter.RebootSystemInstance(instanceID)
|
||||
switch {
|
||||
case err == nil:
|
||||
return RepairDispatched
|
||||
case errors.Is(err, nats.ErrNoResponders), strings.Contains(err.Error(), "no responders"):
|
||||
return RepairUnreachable
|
||||
case errors.Is(err, systeminstance.ErrSystemInstanceNotFound),
|
||||
errors.Is(err, systeminstance.ErrSystemInstanceNotSystem),
|
||||
errors.Is(err, systeminstance.ErrSystemInstanceOwnershipUnknown):
|
||||
return RepairRefused
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (r *lbRepairReconciler) recordOutcome(ctx context.Context, lbID string, at time.Time, instanceID, outcome string) {
|
||||
_, err := r.svc.store.UpdateLBHealth(ctx, lbID, func(cur *LBDataPathRecord) (bool, error) {
|
||||
for i := range cur.Repairs {
|
||||
e := &cur.Repairs[i]
|
||||
if e.At.Equal(at) && e.InstanceID == instanceID && e.Outcome == RepairReserved {
|
||||
e.Outcome = outcome
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "ELBv2 data-path repair: failed to record outcome", "lbId", lbID, "outcome", outcome, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// pruneRepairs drops entries older than the budget window.
|
||||
func pruneRepairs(repairs []LBRepair, now time.Time) []LBRepair {
|
||||
kept := repairs[:0:0]
|
||||
for _, e := range repairs {
|
||||
if now.Sub(e.At) < lbRepairBudgetWindow {
|
||||
kept = append(kept, e)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
func repairLogAttrs(lb *LoadBalancerRecord, c lbRepairCandidate, attempt int, reason string, now time.Time) []any {
|
||||
return []any{
|
||||
"lbArn", lb.LoadBalancerArn, "lbId", lb.LoadBalancerID, "instanceId", lb.InstanceID, "nodeId", lb.NodeID,
|
||||
"eniId", c.nic.ENIID, "mac", c.mac, "failing_since", c.nic.FailingSince,
|
||||
"failing_ms", otelsetup.Millis(now.Sub(c.nic.FailingSince)),
|
||||
"consecutive_failures", c.nic.ConsecutiveFailures,
|
||||
"rx_packets", c.nic.RxPackets, "tx_packets", c.nic.TxPackets,
|
||||
"rx_dropped", c.nic.RxDropped, "tx_dropped", c.nic.TxDropped,
|
||||
"repair_attempt", attempt, "reason", reason,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *lbRepairReconciler) reportBudgetSpent(ctx context.Context, c lbRepairCandidate, n int) {
|
||||
r.mu.Lock()
|
||||
already := r.budgetReported[c.lb.LoadBalancerID]
|
||||
r.budgetReported[c.lb.LoadBalancerID] = true
|
||||
r.mu.Unlock()
|
||||
if already {
|
||||
return
|
||||
}
|
||||
slog.ErrorContext(ctx, "ELBv2 data-path repair budget exhausted",
|
||||
"lbArn", c.lb.LoadBalancerArn, "lbId", c.lb.LoadBalancerID, "instanceId", c.lb.InstanceID,
|
||||
"eniId", c.nic.ENIID, "mac", c.mac, "repairs_24h", n)
|
||||
}
|
||||
|
||||
func (r *lbRepairReconciler) clearBudgetReport(lbID string) {
|
||||
r.mu.Lock()
|
||||
delete(r.budgetReported, lbID)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *lbRepairReconciler) forgetBudgetReports(live map[string]bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for id := range r.budgetReported {
|
||||
if !live[id] {
|
||||
delete(r.budgetReported, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
package elbv2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/systeminstance"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const repairMAC = "02:00:00:bb:00:01"
|
||||
|
||||
type repairEnv struct {
|
||||
t *testing.T
|
||||
svc *ELBv2ServiceImpl
|
||||
rec *lbRepairReconciler
|
||||
launcher *mockSystemInstanceLauncher
|
||||
clock time.Time
|
||||
leader bool
|
||||
}
|
||||
|
||||
func newRepairEnv(t *testing.T) *repairEnv {
|
||||
t.Helper()
|
||||
e := &repairEnv{
|
||||
t: t,
|
||||
svc: setupTestService(t),
|
||||
launcher: &mockSystemInstanceLauncher{},
|
||||
clock: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC),
|
||||
leader: true,
|
||||
}
|
||||
e.rec = newLBRepairReconciler(e.svc, e.launcher, func() bool { return e.leader })
|
||||
e.rec.now = func() time.Time { return e.clock }
|
||||
return e
|
||||
}
|
||||
|
||||
// seed stores an active LB whose VM has run for ten minutes and whose data NIC
|
||||
// has been failing for two, with a heartbeat from this instant.
|
||||
func (e *repairEnv) seed(id string, failing bool) *LoadBalancerRecord {
|
||||
e.t.Helper()
|
||||
lb := &LoadBalancerRecord{
|
||||
LoadBalancerArn: "arn:aws:elasticloadbalancing:us-east-1:" + testAccountID + ":loadbalancer/app/" + id + "/" + id,
|
||||
LoadBalancerID: id,
|
||||
Name: id,
|
||||
State: StateActive,
|
||||
InstanceID: "i-" + id,
|
||||
AccountID: testAccountID,
|
||||
NodeID: "node-1",
|
||||
CreatedAt: e.clock.Add(-time.Hour),
|
||||
ActiveSince: e.clock.Add(-10 * time.Minute),
|
||||
LastHeartbeat: e.clock,
|
||||
}
|
||||
require.NoError(e.t, e.svc.store.PutLoadBalancer(context.Background(), lb))
|
||||
if failing {
|
||||
e.fail(id, 2*time.Minute)
|
||||
}
|
||||
return lb
|
||||
}
|
||||
|
||||
func (e *repairEnv) fail(id string, age time.Duration) {
|
||||
e.t.Helper()
|
||||
_, err := e.svc.store.UpdateLoadBalancer(context.Background(), id, func(lb *LoadBalancerRecord) (bool, error) {
|
||||
lb.LastHeartbeat = e.clock
|
||||
return true, nil
|
||||
})
|
||||
require.NoError(e.t, err)
|
||||
_, err = e.svc.store.UpdateLBHealth(context.Background(), id, func(rec *LBDataPathRecord) (bool, error) {
|
||||
rec.InstanceID = "i-" + id
|
||||
rec.NICs = map[string]*LBNICFailure{
|
||||
repairMAC: {ENIID: "eni-" + id, FailingSince: e.clock.Add(-age), ConsecutiveFailures: 9, RxPackets: 4, TxPackets: 8},
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
require.NoError(e.t, err)
|
||||
}
|
||||
|
||||
func (e *repairEnv) health(id string) *LBDataPathRecord {
|
||||
e.t.Helper()
|
||||
rec, err := e.svc.store.GetLBHealth(context.Background(), id)
|
||||
require.NoError(e.t, err)
|
||||
require.NotNil(e.t, rec)
|
||||
return rec
|
||||
}
|
||||
|
||||
func (e *repairEnv) pass() { e.rec.pass(context.Background()) }
|
||||
|
||||
func (e *repairEnv) reboots() []string {
|
||||
e.launcher.mu.Lock()
|
||||
defer e.launcher.mu.Unlock()
|
||||
return append([]string(nil), e.launcher.rebootCalls...)
|
||||
}
|
||||
|
||||
func captureLogs(t *testing.T) *bytes.Buffer {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
return &buf
|
||||
}
|
||||
|
||||
func TestLBRepair_RepairsOnlyAfterSixtySecondsOfFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newRepairEnv(t)
|
||||
e.seed("lbr1", false)
|
||||
e.fail("lbr1", 59*time.Second)
|
||||
e.pass()
|
||||
assert.Empty(t, e.reboots())
|
||||
|
||||
e.clock = e.clock.Add(2 * time.Second)
|
||||
e.pass()
|
||||
assert.Equal(t, []string{"i-lbr1"}, e.reboots())
|
||||
h := e.health("lbr1")
|
||||
require.Len(t, h.Repairs, 1)
|
||||
assert.Equal(t, RepairDispatched, h.Repairs[0].Outcome)
|
||||
assert.Equal(t, "eni-lbr1", h.Repairs[0].ENIID)
|
||||
}
|
||||
|
||||
func TestLBRepair_ContinuouslyHeartbeatingLBIsNeverStale(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A healthy agent persists its heartbeat only every minute, so the stored
|
||||
// timestamp can be any age below that plus one pass at the reconciler.
|
||||
for age := time.Duration(0); age < lbRepairHeartbeatMaxAge; age += 15 * time.Second {
|
||||
e := newRepairEnv(t)
|
||||
lb := e.seed("lbr2", true)
|
||||
lb.LastHeartbeat = e.clock.Add(-age)
|
||||
require.NoError(t, e.svc.store.PutLoadBalancer(context.Background(), lb))
|
||||
e.pass()
|
||||
assert.Len(t, e.reboots(), 1, "heartbeat %s old", age)
|
||||
}
|
||||
|
||||
e := newRepairEnv(t)
|
||||
lb := e.seed("lbr2", true)
|
||||
lb.LastHeartbeat = e.clock.Add(-lbRepairHeartbeatMaxAge)
|
||||
require.NoError(t, e.svc.store.PutLoadBalancer(context.Background(), lb))
|
||||
e.pass()
|
||||
assert.Empty(t, e.reboots(), "a heartbeat older than the bound is stale")
|
||||
}
|
||||
|
||||
func TestLBRepair_NeverWithoutCurrentEvidence(t *testing.T) {
|
||||
t.Parallel()
|
||||
mutate := map[string]func(*LoadBalancerRecord){
|
||||
"non-active LB": func(lb *LoadBalancerRecord) { lb.State = StateProvisioning },
|
||||
"failed LB": func(lb *LoadBalancerRecord) { lb.State = StateFailed },
|
||||
"empty instance ID": func(lb *LoadBalancerRecord) { lb.InstanceID = "" },
|
||||
"changed instance": func(lb *LoadBalancerRecord) { lb.InstanceID = "i-other" },
|
||||
"stale heartbeat": func(lb *LoadBalancerRecord) { lb.LastHeartbeat = lb.LastHeartbeat.Add(-time.Hour) },
|
||||
"VM under 5 minutes": func(lb *LoadBalancerRecord) { lb.ActiveSince = lb.LastHeartbeat.Add(-4 * time.Minute) },
|
||||
}
|
||||
for name, fn := range mutate {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newRepairEnv(t)
|
||||
lb := e.seed("lbr3", true)
|
||||
fn(lb)
|
||||
require.NoError(t, e.svc.store.PutLoadBalancer(context.Background(), lb))
|
||||
e.pass()
|
||||
assert.Empty(t, e.reboots())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLBRepair_NonHolderNeverRepairs(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newRepairEnv(t)
|
||||
e.seed("lbr4", true)
|
||||
e.leader = false
|
||||
e.pass()
|
||||
assert.Empty(t, e.reboots())
|
||||
e.leader = true
|
||||
e.pass()
|
||||
assert.Len(t, e.reboots(), 1)
|
||||
}
|
||||
|
||||
func TestLBRepair_OneRepairPerPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newRepairEnv(t)
|
||||
e.seed("lbr5a", true)
|
||||
e.seed("lbr5b", true)
|
||||
for i := range 3 {
|
||||
e.seed(fmt.Sprintf("lbr5h%d", i), false)
|
||||
}
|
||||
e.fail("lbr5b", 3*time.Minute)
|
||||
|
||||
e.pass()
|
||||
assert.Equal(t, []string{"i-lbr5b"}, e.reboots(), "the longest-failing LB goes first")
|
||||
e.pass()
|
||||
assert.Equal(t, []string{"i-lbr5b", "i-lbr5a"}, e.reboots())
|
||||
}
|
||||
|
||||
func TestLBRepair_FleetWideFailureRepairsNothing(t *testing.T) {
|
||||
e := newRepairEnv(t)
|
||||
logs := captureLogs(t)
|
||||
e.seed("lbr6a", true)
|
||||
e.seed("lbr6b", true)
|
||||
e.seed("lbr6c", true)
|
||||
e.seed("lbr6d", false)
|
||||
e.pass()
|
||||
assert.Empty(t, e.reboots())
|
||||
assert.Contains(t, logs.String(), "ELBv2 data-path repair suppressed: fleet-wide probe failure")
|
||||
|
||||
// Three failing out of seven is not more than half.
|
||||
for i := range 3 {
|
||||
e.seed(fmt.Sprintf("lbr6h%d", i), false)
|
||||
}
|
||||
e.pass()
|
||||
assert.Len(t, e.reboots(), 1)
|
||||
}
|
||||
|
||||
func TestLBRepair_CooldownAndDailyBudget(t *testing.T) {
|
||||
e := newRepairEnv(t)
|
||||
logs := captureLogs(t)
|
||||
e.seed("lbr7", true)
|
||||
|
||||
e.pass()
|
||||
require.Len(t, e.reboots(), 1)
|
||||
|
||||
e.clock = e.clock.Add(9 * time.Minute)
|
||||
e.fail("lbr7", 2*time.Minute)
|
||||
e.pass()
|
||||
assert.Len(t, e.reboots(), 1, "inside the 10 minute cooldown")
|
||||
|
||||
for want := 2; want <= 3; want++ {
|
||||
e.clock = e.clock.Add(11 * time.Minute)
|
||||
e.fail("lbr7", 2*time.Minute)
|
||||
e.pass()
|
||||
assert.Len(t, e.reboots(), want)
|
||||
}
|
||||
|
||||
e.clock = e.clock.Add(11 * time.Minute)
|
||||
e.fail("lbr7", 2*time.Minute)
|
||||
e.pass()
|
||||
e.pass()
|
||||
assert.Len(t, e.reboots(), 3, "the fourth repair in 24 hours is refused")
|
||||
assert.Equal(t, 1, strings.Count(logs.String(), "ELBv2 data-path repair budget exhausted"))
|
||||
|
||||
e.clock = e.clock.Add(24 * time.Hour)
|
||||
e.fail("lbr7", 2*time.Minute)
|
||||
e.pass()
|
||||
assert.Len(t, e.reboots(), 4, "the budget frees after 24 hours")
|
||||
}
|
||||
|
||||
func TestLBRepair_UnreachableOwnerIsRetriedAndNotCounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newRepairEnv(t)
|
||||
e.launcher.rebootErr = fmt.Errorf("nats request: %w", nats.ErrNoResponders)
|
||||
e.seed("lbr8", true)
|
||||
for range 4 {
|
||||
e.pass()
|
||||
e.clock = e.clock.Add(30 * time.Second)
|
||||
e.fail("lbr8", 2*time.Minute)
|
||||
}
|
||||
assert.Len(t, e.reboots(), 4, "no node received it, so cooldown and budget do not apply")
|
||||
for _, r := range e.health("lbr8").Repairs {
|
||||
assert.Equal(t, RepairUnreachable, r.Outcome)
|
||||
}
|
||||
|
||||
e.launcher.rebootErr = nil
|
||||
e.pass()
|
||||
assert.Len(t, e.reboots(), 5)
|
||||
}
|
||||
|
||||
func TestLBRepair_RefusedAndAmbiguousRepairsStillCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
for name, err := range map[string]error{
|
||||
"refused": fmt.Errorf("%w: i-lbr9", systeminstance.ErrSystemInstanceNotSystem),
|
||||
"lost": errors.New("timeout waiting for reply"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newRepairEnv(t)
|
||||
e.launcher.rebootErr = err
|
||||
e.seed("lbr9", true)
|
||||
e.pass()
|
||||
e.clock = e.clock.Add(30 * time.Second)
|
||||
e.fail("lbr9", 2*time.Minute)
|
||||
e.pass()
|
||||
assert.Len(t, e.reboots(), 1, "inside the cooldown")
|
||||
want := RepairRefused
|
||||
if name == "lost" {
|
||||
want = RepairReserved
|
||||
}
|
||||
assert.Equal(t, want, e.health("lbr9").Repairs[0].Outcome)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLBRepair_ReservationWithoutOutcomeCountsAfterCrashOrLostLease(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newRepairEnv(t)
|
||||
e.seed("lbr10", true)
|
||||
// A leader reserved and died before dispatching.
|
||||
_, err := e.svc.store.UpdateLBHealth(context.Background(), "lbr10", func(rec *LBDataPathRecord) (bool, error) {
|
||||
rec.Repairs = append(rec.Repairs, LBRepair{At: e.clock.Add(-time.Minute), InstanceID: "i-lbr10", Outcome: RepairReserved})
|
||||
return true, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
e.pass()
|
||||
assert.Empty(t, e.reboots(), "the new leader sees the counted reservation")
|
||||
|
||||
e.leader = false
|
||||
e.pass()
|
||||
e.leader = true
|
||||
assert.Empty(t, e.reboots())
|
||||
}
|
||||
|
||||
func TestLBRepair_PausedBetweenEligibilityAndReservationRepairsNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]func(e *repairEnv, lb *LoadBalancerRecord){
|
||||
"DeleteLoadBalancer starts": func(e *repairEnv, lb *LoadBalancerRecord) {
|
||||
require.NoError(t, e.svc.store.SuspendLBHealth(context.Background(), lb.LoadBalancerID))
|
||||
},
|
||||
"SetSubnets relaunches the VM": func(e *repairEnv, lb *LoadBalancerRecord) {
|
||||
lb.InstanceID = "i-new"
|
||||
require.NoError(t, e.svc.store.PutLoadBalancer(context.Background(), lb))
|
||||
},
|
||||
"SetSubnets rebinds the evidence": func(e *repairEnv, lb *LoadBalancerRecord) {
|
||||
require.NoError(t, e.svc.store.ResumeLBHealth(context.Background(), lb.LoadBalancerID, "i-new"))
|
||||
},
|
||||
}
|
||||
for name, interleave := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newRepairEnv(t)
|
||||
lb := e.seed("lbr11", true)
|
||||
cands := e.candidates()
|
||||
require.Len(t, cands, 1)
|
||||
require.True(t, e.rec.eligible(context.Background(), cands[0], e.clock))
|
||||
|
||||
interleave(e, lb)
|
||||
|
||||
assert.False(t, e.rec.repair(context.Background(), cands[0]))
|
||||
assert.Empty(t, e.reboots())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (e *repairEnv) candidates() []lbRepairCandidate {
|
||||
e.t.Helper()
|
||||
var out []lbRepairCandidate
|
||||
lbs, err := e.svc.store.ListLoadBalancersStrict(context.Background())
|
||||
require.NoError(e.t, err)
|
||||
for _, lb := range lbs {
|
||||
rec, err := e.svc.store.GetLBHealth(context.Background(), lb.LoadBalancerID)
|
||||
require.NoError(e.t, err)
|
||||
if rec == nil {
|
||||
continue
|
||||
}
|
||||
c := lbRepairCandidate{lb: lb, rec: rec}
|
||||
for mac, nic := range rec.NICs {
|
||||
c.mac, c.nic, c.since = mac, nic, nic.FailingSince
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Verifies: ADR-0003:S7.
|
||||
func TestS7_LBRepairActsOnlyOnTheLBsOwnVM(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newRepairEnv(t)
|
||||
e.seed("lbr12", true)
|
||||
e.seed("lbr12ok", false)
|
||||
e.pass()
|
||||
e.pass()
|
||||
assert.Equal(t, []string{"i-lbr12"}, e.reboots(), "only the failing LB's own instance ID reaches the launcher")
|
||||
e.launcher.mu.Lock()
|
||||
defer e.launcher.mu.Unlock()
|
||||
assert.Empty(t, e.launcher.terminateCalls)
|
||||
assert.Empty(t, e.launcher.launchCalls)
|
||||
}
|
||||
|
||||
func TestLBRepair_TeardownNeverTriggersRepair(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
t.Run("DeleteLoadBalancer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newRepairEnv(t)
|
||||
lb := e.seed("lbr13a", true)
|
||||
require.NoError(t, e.svc.store.SuspendLBHealth(ctx, lb.LoadBalancerID))
|
||||
e.pass()
|
||||
assert.Empty(t, e.reboots())
|
||||
})
|
||||
t.Run("SetSubnets window", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newRepairEnv(t)
|
||||
lb := e.seed("lbr13b", true)
|
||||
require.NoError(t, e.svc.store.SuspendLBHealth(ctx, lb.LoadBalancerID))
|
||||
lb.InstanceID = "i-relaunched"
|
||||
require.NoError(t, e.svc.store.PutLoadBalancer(ctx, lb))
|
||||
e.pass()
|
||||
assert.Empty(t, e.reboots(), "suspended, and the old VM's evidence names another instance")
|
||||
})
|
||||
t.Run("transient probe failure on the relaunched VM", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newRepairEnv(t)
|
||||
lb := e.seed("lbr13c", false)
|
||||
lb.InstanceID = "i-relaunched"
|
||||
require.NoError(t, e.svc.store.PutLoadBalancer(ctx, lb))
|
||||
require.NoError(t, e.svc.store.ResumeLBHealth(ctx, lb.LoadBalancerID, "i-relaunched"))
|
||||
_, err := e.svc.store.UpdateLBHealth(ctx, lb.LoadBalancerID, func(rec *LBDataPathRecord) (bool, error) {
|
||||
rec.BoundAt = e.clock.Add(-90 * time.Second)
|
||||
rec.NICs = map[string]*LBNICFailure{repairMAC: {FailingSince: e.clock.Add(-80 * time.Second)}}
|
||||
return true, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
e.pass()
|
||||
assert.Empty(t, e.reboots(), "a freshly relaunched VM gets five minutes")
|
||||
})
|
||||
}
|
||||
@@ -1138,7 +1138,6 @@ func (s *ELBv2ServiceImpl) LBAgentHeartbeat(ctx context.Context, input *LBAgentH
|
||||
if rec.State == StateProvisioning {
|
||||
rec.State = StateActive
|
||||
rec.StateReason = ""
|
||||
rec.ActiveSince = now
|
||||
}
|
||||
rec.LastHeartbeat = now
|
||||
return true, nil
|
||||
@@ -1163,8 +1162,6 @@ func (s *ELBv2ServiceImpl) LBAgentHeartbeat(ctx context.Context, input *LBAgentH
|
||||
}
|
||||
}
|
||||
|
||||
s.recordDataPathEvidence(ctx, lb, input)
|
||||
|
||||
// Process health report directly — no JSON round-trip needed.
|
||||
if len(input.Servers) > 0 {
|
||||
s.hc.handleHealthReportDirect(ctx, input.toHealthReport())
|
||||
@@ -1856,23 +1853,15 @@ func (s *ELBv2ServiceImpl) DeleteLoadBalancer(ctx context.Context, input *elbv2.
|
||||
return &elbv2.DeleteLoadBalancerOutput{}, nil
|
||||
}
|
||||
|
||||
// Exclude data-path repair before anything touches the VM or its ENIs.
|
||||
if err := s.store.SuspendLBHealth(ctx, lb.LoadBalancerID); err != nil {
|
||||
slog.ErrorContext(ctx, "DeleteLoadBalancer: failed to suspend data-path repair", "lbId", lb.LoadBalancerID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
// Cascade-delete all listeners and their rules so no orphan pins a TG as ResourceInUse.
|
||||
listeners, err := s.store.ListListenersByLB(ctx, lb.LoadBalancerArn)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "DeleteLoadBalancer: failed to list listeners for cascade", "lbArn", lb.LoadBalancerArn, "err", err)
|
||||
s.resumeLBHealth(ctx, lb.LoadBalancerID)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
for _, l := range listeners {
|
||||
if err := s.deleteListenerCascade(ctx, l); err != nil {
|
||||
slog.ErrorContext(ctx, "DeleteLoadBalancer: failed to cascade listener delete", "listenerID", l.ListenerID, "err", err)
|
||||
s.resumeLBHealth(ctx, lb.LoadBalancerID)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
}
|
||||
@@ -1921,9 +1910,6 @@ func (s *ELBv2ServiceImpl) DeleteLoadBalancer(ctx context.Context, input *elbv2.
|
||||
slog.ErrorContext(ctx, "DeleteLoadBalancer: failed to delete record", "lbId", lb.LoadBalancerID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if err := s.store.DeleteLBHealth(ctx, lb.LoadBalancerID); err != nil {
|
||||
slog.WarnContext(ctx, "DeleteLoadBalancer: failed to delete data-path evidence", "lbId", lb.LoadBalancerID, "err", err)
|
||||
}
|
||||
|
||||
// Release the name claim so the name is reusable. Idempotent on a missing
|
||||
// key, so a delete that races the record removal still converges.
|
||||
@@ -3470,9 +3456,6 @@ func (s *ELBv2ServiceImpl) lbRecordToSDK(ctx context.Context, r *LoadBalancerRec
|
||||
if r.StateReason != "" {
|
||||
lb.State.Reason = aws.String(r.StateReason)
|
||||
}
|
||||
if s.impairedByEvidence(ctx, r) {
|
||||
lb.State.Code = aws.String(StateActiveImpaired)
|
||||
}
|
||||
|
||||
for _, sg := range r.SecurityGroups {
|
||||
lb.SecurityGroups = append(lb.SecurityGroups, aws.String(sg))
|
||||
|
||||
@@ -172,18 +172,6 @@ func (s *ELBv2ServiceImpl) SetSubnets(ctx context.Context, input *elbv2.SetSubne
|
||||
return s.setSubnetsOutput(ctx, lb), nil
|
||||
}
|
||||
|
||||
// Exclude data-path repair before any ENI or VM call. It stays excluded once the
|
||||
// VM is terminated and is lifted when the new VM is published.
|
||||
if err := s.store.SuspendLBHealth(ctx, lb.LoadBalancerID); err != nil {
|
||||
slog.ErrorContext(ctx, "SetSubnets: failed to suspend data-path repair", "arn", *input.LoadBalancerArn, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
resume := func() {
|
||||
if err := s.store.ResumeLBHealth(ctx, lb.LoadBalancerID, ""); err != nil {
|
||||
slog.WarnContext(ctx, "SetSubnets: failed to lift data-path suspension", "lbId", lb.LoadBalancerID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create ENIs for added subnets; roll back on failure to avoid leaks.
|
||||
newENIBySubnet := make(map[string]string, len(toAdd))
|
||||
newAZBySubnet := make(map[string]string, len(toAdd))
|
||||
@@ -200,7 +188,6 @@ func (s *ELBv2ServiceImpl) SetSubnets(ctx context.Context, input *elbv2.SetSubne
|
||||
eniID, az, eniErr := s.createLBENI(ctx, subnetID, lb, accountID)
|
||||
if eniErr != nil {
|
||||
rollbackNewENIs()
|
||||
resume()
|
||||
slog.ErrorContext(ctx, "SetSubnets: failed to create ENI", "subnet", subnetID, "err", eniErr)
|
||||
return nil, errors.New(awserrors.ErrorELBv2SubnetNotFound)
|
||||
}
|
||||
@@ -222,7 +209,6 @@ func (s *ELBv2ServiceImpl) SetSubnets(ctx context.Context, input *elbv2.SetSubne
|
||||
if lb.InstanceID != "" && s.InstanceLauncher != nil {
|
||||
if err := s.InstanceLauncher.TerminateSystemInstance(lb.InstanceID); err != nil {
|
||||
rollbackNewENIs()
|
||||
resume()
|
||||
slog.ErrorContext(ctx, "SetSubnets: failed to terminate LB VM for relaunch", "arn", *input.LoadBalancerArn, "instanceId", lb.InstanceID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
@@ -282,9 +268,6 @@ func (s *ELBv2ServiceImpl) SetSubnets(ctx context.Context, input *elbv2.SetSubne
|
||||
slog.ErrorContext(ctx, "SetSubnets: failed to persist LB", "arn", *input.LoadBalancerArn, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if err := s.store.ResumeLBHealth(ctx, lb.LoadBalancerID, lb.InstanceID); err != nil {
|
||||
slog.WarnContext(ctx, "SetSubnets: failed to lift data-path suspension", "lbId", lb.LoadBalancerID, "err", err)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "SetSubnets completed", "arn", *input.LoadBalancerArn, "subnets", len(desired), "added", len(toAdd), "removed", len(toRemove), "state", lb.State)
|
||||
return s.setSubnetsOutput(ctx, lb), nil
|
||||
|
||||
@@ -2108,8 +2108,6 @@ func (m *mockTerminateLauncher) TerminateSystemInstance(instanceID string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockTerminateLauncher) RebootSystemInstance(_ string) error { return nil }
|
||||
|
||||
// --- Scheme unit tests ---
|
||||
|
||||
func TestCreateLoadBalancer_InternetFacingScheme_DNSName(t *testing.T) {
|
||||
|
||||
@@ -96,25 +96,4 @@ func (l *natsSystemInstanceLauncher) TerminateSystemInstance(instanceID string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *natsSystemInstanceLauncher) RebootSystemInstance(instanceID string) error {
|
||||
if instanceID == "" {
|
||||
return fmt.Errorf("system instance reboot: empty instance ID")
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("system.RebootInstance.%s", instanceID)
|
||||
reply, err := l.nc.Request(subject, nil, l.timeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("nats request %s: %w", subject, err)
|
||||
}
|
||||
|
||||
var env natsSystemInstanceTerminateEnvelope
|
||||
if err := json.Unmarshal(reply.Data, &env); err != nil {
|
||||
return fmt.Errorf("decode reboot reply: %w", err)
|
||||
}
|
||||
if env.Error != "" {
|
||||
return fmt.Errorf("%s", env.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ SystemInstanceLauncher = (*natsSystemInstanceLauncher)(nil)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -312,69 +311,3 @@ func TestNATSSystemInstanceLauncher_RefusesNonLoadBalancerKindBeforeSending(t *t
|
||||
defer mu.Unlock()
|
||||
assert.Equal(t, 1, received, "only the load balancer launch may be sent")
|
||||
}
|
||||
|
||||
func TestNATSSystemInstanceLauncher_RebootEmptyID(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
defer nc.Close()
|
||||
|
||||
err := NewNATSSystemInstanceLauncher(nc, time.Second).RebootSystemInstance("")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "empty instance ID")
|
||||
}
|
||||
|
||||
func TestNATSSystemInstanceLauncher_RebootRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
defer nc.Close()
|
||||
|
||||
var asked atomic.Int32
|
||||
sub, err := nc.Subscribe("system.RebootInstance.i-lb", func(msg *nats.Msg) {
|
||||
asked.Add(1)
|
||||
_ = msg.Respond([]byte(`{}`))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
|
||||
require.NoError(t, NewNATSSystemInstanceLauncher(nc, 2*time.Second).RebootSystemInstance("i-lb"))
|
||||
assert.EqualValues(t, 1, asked.Load())
|
||||
}
|
||||
|
||||
func TestNATSSystemInstanceLauncher_RebootNoResponderKeepsTransportError(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
defer nc.Close()
|
||||
|
||||
err := NewNATSSystemInstanceLauncher(nc, 250*time.Millisecond).RebootSystemInstance("i-no-one-listens")
|
||||
require.ErrorIs(t, err, nats.ErrNoResponders)
|
||||
}
|
||||
|
||||
func TestNATSSystemInstanceLauncher_RebootDecodeFailureAndRemoteError(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
defer nc.Close()
|
||||
|
||||
bad, err := nc.Subscribe("system.RebootInstance.i-decode", func(msg *nats.Msg) {
|
||||
_ = msg.Respond([]byte("{not valid json"))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = bad.Unsubscribe() }()
|
||||
|
||||
refused, err := nc.Subscribe("system.RebootInstance.i-refused", func(msg *nats.Msg) {
|
||||
payload, _ := json.Marshal(struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}{Error: "not a system instance"})
|
||||
_ = msg.Respond(payload)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = refused.Unsubscribe() }()
|
||||
|
||||
l := NewNATSSystemInstanceLauncher(nc, 2*time.Second)
|
||||
err = l.RebootSystemInstance("i-decode")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "decode reboot reply")
|
||||
|
||||
err = l.RebootSystemInstance("i-refused")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not a system instance")
|
||||
}
|
||||
|
||||
@@ -155,7 +155,6 @@ type LoadBalancerRecord struct {
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
AccountID string `json:"account_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ActiveSince time.Time `json:"active_since,omitzero"` // First heartbeat of the current VM generation
|
||||
}
|
||||
|
||||
// HealthTargetSpec is a backend the nginx agent actively health-checks, computed
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/lambda"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/eventsource"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/function"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
)
|
||||
|
||||
// MappingStore is what the handlers need of the event-source mapping store.
|
||||
type MappingStore interface {
|
||||
Create(ctx context.Context, m eventsource.Mapping) error
|
||||
Get(ctx context.Context, uuid string) (eventsource.Mapping, uint64, error)
|
||||
List(ctx context.Context) ([]eventsource.Entry, error)
|
||||
Update(ctx context.Context, m eventsource.Mapping, revision uint64) (uint64, error)
|
||||
}
|
||||
|
||||
var _ MappingStore = (*eventsource.Store)(nil)
|
||||
|
||||
// SourceChecker reads an SQS queue's visibility timeout as the function's
|
||||
// execution role, so a queue the role cannot read is refused at create.
|
||||
type SourceChecker interface {
|
||||
QueueVisibilityTimeout(ctx context.Context, rec function.Record, queueARN string) (int64, error)
|
||||
}
|
||||
|
||||
// Batch size bounds for an SQS standard queue without a batching window.
|
||||
const (
|
||||
defaultBatchSize = 10
|
||||
maxBatchSize = 10
|
||||
)
|
||||
|
||||
// mappingCreateFields are the CreateEventSourceMapping members this build
|
||||
// accepts; every other member is refused rather than ignored.
|
||||
var mappingCreateFields = map[string]bool{
|
||||
"FunctionName": true, "EventSourceArn": true, "BatchSize": true, "Enabled": true,
|
||||
"MaximumBatchingWindowInSeconds": true,
|
||||
}
|
||||
|
||||
var mappingUUIDRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||
|
||||
func mappingNotFound() error {
|
||||
return awserrors.Errorf(awserrors.ErrorResourceNotFoundException, "The resource you requested does not exist.")
|
||||
}
|
||||
|
||||
// mappingARN is the IAM resource of one mapping, or "*" for a malformed UUID.
|
||||
func mappingARN(region, accountID, uuid string) string {
|
||||
if region == "" || accountID == "" || !mappingUUIDRE.MatchString(uuid) {
|
||||
return "*"
|
||||
}
|
||||
return fmt.Sprintf("arn:aws:lambda:%s:%s:event-source-mapping:%s", region, accountID, uuid)
|
||||
}
|
||||
|
||||
type mappingCreate struct {
|
||||
FunctionName, QueueARN string
|
||||
BatchSize int64
|
||||
}
|
||||
|
||||
// decodeMappingCreate reads the body strictly and refuses every option this
|
||||
// build does not implement.
|
||||
func decodeMappingCreate(body []byte) (*mappingCreate, error) {
|
||||
var raw map[string]json.RawMessage
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
if err := dec.Decode(&raw); err != nil {
|
||||
return nil, invalid("The request body could not be parsed as JSON.")
|
||||
}
|
||||
for k := range raw {
|
||||
if !mappingCreateFields[k] {
|
||||
return nil, unsupported(k)
|
||||
}
|
||||
}
|
||||
var in struct {
|
||||
FunctionName *string `json:"FunctionName"`
|
||||
EventSourceArn *string `json:"EventSourceArn"`
|
||||
BatchSize *int64 `json:"BatchSize"`
|
||||
Enabled *bool `json:"Enabled"`
|
||||
MaximumBatchingWindowInSeconds *int64 `json:"MaximumBatchingWindowInSeconds"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &in); err != nil {
|
||||
return nil, invalid("The request body could not be parsed as JSON.")
|
||||
}
|
||||
if in.FunctionName == nil || *in.FunctionName == "" {
|
||||
return nil, invalid("1 validation error detected: Value null at 'functionName' failed to satisfy constraint: Member must not be null")
|
||||
}
|
||||
if in.EventSourceArn == nil || *in.EventSourceArn == "" {
|
||||
return nil, invalid("Unrecognized event source, must be kinesis, dynamodb stream or sqs.")
|
||||
}
|
||||
if in.Enabled != nil && !*in.Enabled {
|
||||
return nil, unsupported("Enabled false")
|
||||
}
|
||||
if in.MaximumBatchingWindowInSeconds != nil && *in.MaximumBatchingWindowInSeconds != 0 {
|
||||
return nil, unsupported("MaximumBatchingWindowInSeconds")
|
||||
}
|
||||
m := &mappingCreate{FunctionName: *in.FunctionName, QueueARN: *in.EventSourceArn, BatchSize: defaultBatchSize}
|
||||
if in.BatchSize != nil {
|
||||
if *in.BatchSize < 1 || *in.BatchSize > maxBatchSize {
|
||||
return nil, invalid("BatchSize must be between 1 and %d for an SQS queue without a batching window.", maxBatchSize)
|
||||
}
|
||||
m.BatchSize = *in.BatchSize
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// checkQueueARN refuses anything but a standard SQS queue in the caller's
|
||||
// own account and Region.
|
||||
func checkQueueARN(region, accountID, queueARN string) error {
|
||||
parts := strings.Split(queueARN, ":")
|
||||
if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" {
|
||||
return invalid("Unrecognized event source, must be kinesis, dynamodb stream or sqs.")
|
||||
}
|
||||
if parts[2] != "sqs" {
|
||||
return unsupported("an event source other than SQS")
|
||||
}
|
||||
if parts[3] != region || parts[4] != accountID {
|
||||
return unsupported("a queue in another account or Region")
|
||||
}
|
||||
if parts[5] == "" {
|
||||
return invalid("Unrecognized event source, must be kinesis, dynamodb stream or sqs.")
|
||||
}
|
||||
if strings.HasSuffix(parts[5], ".fifo") {
|
||||
return unsupported("a FIFO queue")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mappingUUID derives one UUID per account, Region, function and queue, so the
|
||||
// keyed create itself refuses a duplicate with no inventory scan or second record.
|
||||
func mappingUUID(accountID, region, function, queueARN string) string {
|
||||
b := sha256.Sum256([]byte(accountID + "\x00" + region + "\x00" + function + "\x00" + queueARN))
|
||||
b[6] = b[6]&0x0f | 0x50
|
||||
b[8] = b[8]&0x3f | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
func mappingShape(m eventsource.Mapping, state string) *lambda.EventSourceMappingConfiguration {
|
||||
return &lambda.EventSourceMappingConfiguration{
|
||||
UUID: aws.String(m.UUID),
|
||||
BatchSize: aws.Int64(m.BatchSize),
|
||||
MaximumBatchingWindowInSeconds: aws.Int64(0),
|
||||
EventSourceArn: aws.String(m.EventSourceARN),
|
||||
FunctionArn: aws.String(m.FunctionARN),
|
||||
LastModified: aws.Time(m.LastModified),
|
||||
State: aws.String(state),
|
||||
StateTransitionReason: aws.String(m.StateTransitionReason),
|
||||
}
|
||||
}
|
||||
|
||||
func (d Deps) mappings(ctx context.Context) (MappingStore, error) {
|
||||
if d.Mappings == nil {
|
||||
return nil, serviceError("event-source mappings are unavailable")
|
||||
}
|
||||
return d.Mappings(ctx)
|
||||
}
|
||||
|
||||
// createEventSourceMapping resolves the function, reads the queue as its
|
||||
// execution role, and records an Enabled mapping its poller picks up.
|
||||
func (d Deps) createEventSourceMapping(ctx context.Context, c *call) (reply, error) {
|
||||
in, err := decodeMappingCreate(c.Body)
|
||||
if err != nil {
|
||||
return reply{}, err
|
||||
}
|
||||
if err := checkQueueARN(c.Region, c.AccountID, in.QueueARN); err != nil {
|
||||
return reply{}, err
|
||||
}
|
||||
name, q, err := resolveFunctionName(c.Region, c.AccountID, in.FunctionName)
|
||||
if err != nil {
|
||||
return reply{}, err
|
||||
}
|
||||
if q != "" {
|
||||
return reply{}, unsupported("a qualified FunctionName")
|
||||
}
|
||||
rec, _, err := d.Meta.Get(ctx, c.AccountID, c.Region, name)
|
||||
if errors.Is(err, function.ErrNotFound) {
|
||||
return reply{}, notFound(c.Region, c.AccountID, name)
|
||||
}
|
||||
if err != nil {
|
||||
return reply{}, err
|
||||
}
|
||||
if d.Sources == nil {
|
||||
return reply{}, serviceError("event-source mappings are unavailable")
|
||||
}
|
||||
visibility, err := d.Sources.QueueVisibilityTimeout(ctx, rec, in.QueueARN)
|
||||
if err != nil {
|
||||
return reply{}, err
|
||||
}
|
||||
if rec.Timeout > visibility {
|
||||
return reply{}, invalid("Queue visibility timeout: %d seconds is less than Function timeout: %d seconds", visibility, rec.Timeout)
|
||||
}
|
||||
store, err := d.mappings(ctx)
|
||||
if err != nil {
|
||||
return reply{}, err
|
||||
}
|
||||
uuid := mappingUUID(c.AccountID, c.Region, name, in.QueueARN)
|
||||
m := eventsource.Mapping{
|
||||
FormatVersion: eventsource.FormatVersion, UUID: uuid, AccountID: c.AccountID, Region: c.Region,
|
||||
FunctionName: name, FunctionARN: arn.FormatLambdaFunction(c.Region, c.AccountID, name),
|
||||
EventSourceARN: in.QueueARN, BatchSize: in.BatchSize, State: eventsource.StateEnabled,
|
||||
StateTransitionReason: eventsource.ReasonUserInitiated, LastModified: time.Now().UTC().Truncate(time.Millisecond),
|
||||
}
|
||||
if err := store.Create(ctx, m); err != nil {
|
||||
if errors.Is(err, eventsource.ErrConflict) {
|
||||
return reply{}, awserrors.Errorf(awserrors.ErrorResourceConflictException,
|
||||
"An event source mapping with SQS arn (\" %s \") and function (\" %s \") already exists. Please update or delete the existing mapping with UUID %s",
|
||||
in.QueueARN, name, uuid)
|
||||
}
|
||||
return reply{}, err
|
||||
}
|
||||
return reply{Status: http.StatusAccepted, Body: mappingShape(m, "Creating")}, nil
|
||||
}
|
||||
|
||||
// ownMapping reads a mapping of the caller's account and Region.
|
||||
func (d Deps) ownMapping(ctx context.Context, c *call) (MappingStore, eventsource.Mapping, uint64, error) {
|
||||
if !mappingUUIDRE.MatchString(c.Params[0]) {
|
||||
return nil, eventsource.Mapping{}, 0, mappingNotFound()
|
||||
}
|
||||
store, err := d.mappings(ctx)
|
||||
if err != nil {
|
||||
return nil, eventsource.Mapping{}, 0, err
|
||||
}
|
||||
m, rev, err := store.Get(ctx, c.Params[0])
|
||||
if errors.Is(err, eventsource.ErrNotFound) || (err == nil && (m.AccountID != c.AccountID || m.Region != c.Region)) {
|
||||
return nil, eventsource.Mapping{}, 0, mappingNotFound()
|
||||
}
|
||||
return store, m, rev, err
|
||||
}
|
||||
|
||||
func (d Deps) getEventSourceMapping(ctx context.Context, c *call) (reply, error) {
|
||||
_, m, _, err := d.ownMapping(ctx, c)
|
||||
if err != nil {
|
||||
return reply{}, err
|
||||
}
|
||||
return reply{Status: http.StatusOK, Body: mappingShape(m, m.State)}, nil
|
||||
}
|
||||
|
||||
// deleteEventSourceMapping records Deleting and returns; the poller stops its
|
||||
// worker and only then removes the record.
|
||||
func (d Deps) deleteEventSourceMapping(ctx context.Context, c *call) (reply, error) {
|
||||
for range 3 {
|
||||
store, m, rev, err := d.ownMapping(ctx, c)
|
||||
if err != nil {
|
||||
return reply{}, err
|
||||
}
|
||||
if m.State == eventsource.StateDeleting {
|
||||
return reply{Status: http.StatusAccepted, Body: mappingShape(m, m.State)}, nil
|
||||
}
|
||||
m.State = eventsource.StateDeleting
|
||||
m.StateTransitionReason = eventsource.ReasonUserInitiated
|
||||
m.LastModified = time.Now().UTC().Truncate(time.Millisecond)
|
||||
_, err = store.Update(ctx, m, rev)
|
||||
if errors.Is(err, eventsource.ErrConflict) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return reply{}, err
|
||||
}
|
||||
return reply{Status: http.StatusAccepted, Body: mappingShape(m, m.State)}, nil
|
||||
}
|
||||
return reply{}, serviceError("the mapping changed concurrently; retry the request")
|
||||
}
|
||||
|
||||
// listEventSourceMappings is refused: the mapping store can only be listed
|
||||
// whole, and a request must not wait on the whole inventory.
|
||||
func (d Deps) listEventSourceMappings(_ context.Context, _ *call) (reply, error) {
|
||||
return reply{}, invalid("ListEventSourceMappings is not supported in this build; use GetEventSourceMapping with the mapping UUID")
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
package awsapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
testutil "github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/internal/testkit/gwtest"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/eventsource"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/function"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeSources struct {
|
||||
mu sync.Mutex
|
||||
visibility int64
|
||||
err error
|
||||
asRole []string
|
||||
}
|
||||
|
||||
func (f *fakeSources) QueueVisibilityTimeout(_ context.Context, rec function.Record, _ string) (int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.asRole = append(f.asRole, rec.Role)
|
||||
return f.visibility, f.err
|
||||
}
|
||||
|
||||
const mappingsPath = "/2015-03-31/event-source-mappings/"
|
||||
|
||||
var queueARN = "arn:aws:sqs:" + gwtest.Region + ":" + gwtest.AccountID + ":orders"
|
||||
|
||||
func newMappingEnv(t *testing.T, statements ...iamdomain.Statement) (*env, *eventsource.Store, *fakeSources) {
|
||||
t.Helper()
|
||||
e := newEnv(t, statements...)
|
||||
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", func(b map[string]any) { b["Timeout"] = 30 }))
|
||||
require.NoError(t, err)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
store, err := eventsource.Open(t.Context(), js)
|
||||
require.NoError(t, err)
|
||||
src := &fakeSources{visibility: 30}
|
||||
e.deps.Mappings = func(context.Context) (awsapi.MappingStore, error) { return store, nil }
|
||||
e.deps.Sources = src
|
||||
return e, store, src
|
||||
}
|
||||
|
||||
func mappingBody(mutate func(map[string]any)) map[string]any {
|
||||
b := map[string]any{"FunctionName": "hello", "EventSourceArn": queueARN}
|
||||
if mutate != nil {
|
||||
mutate(b)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func decodeMapping(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &out))
|
||||
return out
|
||||
}
|
||||
|
||||
func TestEventSourceMapping_CreateGetDelete(t *testing.T) {
|
||||
e, store, src := newMappingEnv(t)
|
||||
rec, err := e.do(http.MethodPost, mappingsPath, mappingBody(func(b map[string]any) { b["BatchSize"] = 4 }))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusAccepted, rec.Code)
|
||||
created := decodeMapping(t, rec.Body.Bytes())
|
||||
assert.Equal(t, "Creating", created["State"])
|
||||
assert.EqualValues(t, 4, created["BatchSize"])
|
||||
assert.Equal(t, queueARN, created["EventSourceArn"])
|
||||
assert.Equal(t, []string{roleARN}, src.asRole, "the queue is read as the function's execution role")
|
||||
uuid, _ := created["UUID"].(string)
|
||||
|
||||
stored, _, err := store.Get(t.Context(), uuid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, eventsource.StateEnabled, stored.State)
|
||||
|
||||
rec, err = e.do(http.MethodGet, mappingsPath+uuid, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Enabled", decodeMapping(t, rec.Body.Bytes())["State"])
|
||||
|
||||
_, err = e.do(http.MethodPost, mappingsPath, mappingBody(nil))
|
||||
testutil.RequireAWSCode(t, err, awserrors.ErrorResourceConflictException)
|
||||
|
||||
for range 2 {
|
||||
rec, err = e.do(http.MethodDelete, mappingsPath+uuid, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusAccepted, rec.Code)
|
||||
assert.Equal(t, "Deleting", decodeMapping(t, rec.Body.Bytes())["State"])
|
||||
}
|
||||
stored, _, err = store.Get(t.Context(), uuid)
|
||||
require.NoError(t, err, "delete leaves the record for the poller to remove")
|
||||
assert.Equal(t, eventsource.StateDeleting, stored.State)
|
||||
}
|
||||
|
||||
func TestEventSourceMapping_ConcurrentDuplicateCreatesYieldOneMapping(t *testing.T) {
|
||||
first, store, src := newMappingEnv(t)
|
||||
const n = 8
|
||||
gateways := []*env{first}
|
||||
for len(gateways) < n {
|
||||
e := newEnv(t)
|
||||
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", func(b map[string]any) { b["Timeout"] = 30 }))
|
||||
require.NoError(t, err)
|
||||
e.deps.Mappings = first.deps.Mappings
|
||||
e.deps.Sources = src
|
||||
gateways = append(gateways, e)
|
||||
}
|
||||
codes := make(chan error, n)
|
||||
var wg sync.WaitGroup
|
||||
for _, e := range gateways {
|
||||
wg.Go(func() {
|
||||
_, err := e.do(http.MethodPost, mappingsPath, mappingBody(nil))
|
||||
codes <- err
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
close(codes)
|
||||
created := 0
|
||||
for err := range codes {
|
||||
if err == nil {
|
||||
created++
|
||||
continue
|
||||
}
|
||||
testutil.RequireAWSCode(t, err, awserrors.ErrorResourceConflictException)
|
||||
}
|
||||
assert.Equal(t, 1, created)
|
||||
entries, err := store.List(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 1)
|
||||
}
|
||||
|
||||
func TestEventSourceMapping_ListIsRefusedWithoutReadingTheStore(t *testing.T) {
|
||||
e, store, _ := newMappingEnv(t)
|
||||
_, err := e.do(http.MethodPost, mappingsPath, mappingBody(nil))
|
||||
require.NoError(t, err)
|
||||
for _, path := range []string{mappingsPath, mappingsPath + "?FunctionName=hello", mappingsPath + "?MaxItems=2"} {
|
||||
_, err = e.do(http.MethodGet, path, nil)
|
||||
testutil.RequireAWSCode(t, err, awserrors.ErrorInvalidParameterValueException)
|
||||
}
|
||||
entries, err := store.List(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 1, "the refusal leaves the mapping in place")
|
||||
}
|
||||
|
||||
func TestEventSourceMapping_RefusesWhatThisBuildDoesNotImplement(t *testing.T) {
|
||||
e, store, _ := newMappingEnv(t)
|
||||
cases := map[string]func(map[string]any){
|
||||
"FIFO queue": func(b map[string]any) { b["EventSourceArn"] = queueARN + ".fifo" },
|
||||
"another account": func(b map[string]any) { b["EventSourceArn"] = "arn:aws:sqs:" + gwtest.Region + ":210987654321:orders" },
|
||||
"another Region": func(b map[string]any) { b["EventSourceArn"] = "arn:aws:sqs:us-east-1:" + gwtest.AccountID + ":orders" },
|
||||
"not SQS": func(b map[string]any) {
|
||||
b["EventSourceArn"] = "arn:aws:kinesis:" + gwtest.Region + ":" + gwtest.AccountID + ":stream/s"
|
||||
},
|
||||
"filter criteria": func(b map[string]any) { b["FilterCriteria"] = map[string]any{"Filters": []any{}} },
|
||||
"disabled": func(b map[string]any) { b["Enabled"] = false },
|
||||
"batching window": func(b map[string]any) { b["MaximumBatchingWindowInSeconds"] = 5 },
|
||||
"batch over 10": func(b map[string]any) { b["BatchSize"] = 11 },
|
||||
"batch zero": func(b map[string]any) { b["BatchSize"] = 0 },
|
||||
"qualified name": func(b map[string]any) { b["FunctionName"] = "hello:1" },
|
||||
"missing source": func(b map[string]any) { delete(b, "EventSourceArn") },
|
||||
"missing function": func(b map[string]any) { delete(b, "FunctionName") },
|
||||
"starting position": func(b map[string]any) { b["StartingPosition"] = "LATEST" },
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := e.do(http.MethodPost, mappingsPath, mappingBody(mutate))
|
||||
testutil.RequireAWSCode(t, err, awserrors.ErrorInvalidParameterValueException)
|
||||
})
|
||||
}
|
||||
entries, err := store.List(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries)
|
||||
}
|
||||
|
||||
func TestEventSourceMapping_VisibilityTimeoutMustCoverTheFunctionTimeout(t *testing.T) {
|
||||
e, store, src := newMappingEnv(t)
|
||||
src.visibility = 29
|
||||
_, err := e.do(http.MethodPost, mappingsPath, mappingBody(nil))
|
||||
testutil.RequireAWSCode(t, err, awserrors.ErrorInvalidParameterValueException)
|
||||
|
||||
src.visibility, src.err = 0, awserrors.Errorf(awserrors.ErrorAccessDeniedException, "denied")
|
||||
_, err = e.do(http.MethodPost, mappingsPath, mappingBody(nil))
|
||||
testutil.RequireAWSCode(t, err, awserrors.ErrorAccessDeniedException)
|
||||
|
||||
entries, err := store.List(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries)
|
||||
|
||||
_, err = e.do(http.MethodPost, mappingsPath, mappingBody(func(b map[string]any) { b["FunctionName"] = "absent" }))
|
||||
testutil.RequireAWSCode(t, err, awserrors.ErrorResourceNotFoundException)
|
||||
}
|
||||
|
||||
func TestEventSourceMapping_AnotherAccountsMappingIsNotFound(t *testing.T) {
|
||||
e, store, _ := newMappingEnv(t)
|
||||
m := eventsource.Mapping{
|
||||
FormatVersion: eventsource.FormatVersion, UUID: "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", AccountID: "210987654321",
|
||||
Region: gwtest.Region, FunctionName: "hello", EventSourceARN: "arn:aws:sqs:" + gwtest.Region + ":210987654321:orders",
|
||||
BatchSize: 1, State: eventsource.StateEnabled,
|
||||
}
|
||||
require.NoError(t, store.Create(t.Context(), m))
|
||||
_, err := e.do(http.MethodGet, mappingsPath+m.UUID, nil)
|
||||
testutil.RequireAWSCode(t, err, awserrors.ErrorResourceNotFoundException)
|
||||
_, err = e.do(http.MethodDelete, mappingsPath+m.UUID, nil)
|
||||
testutil.RequireAWSCode(t, err, awserrors.ErrorResourceNotFoundException)
|
||||
_, err = e.do(http.MethodGet, mappingsPath+"not-a-uuid", nil)
|
||||
testutil.RequireAWSCode(t, err, awserrors.ErrorResourceNotFoundException)
|
||||
}
|
||||
|
||||
func TestEventSourceMapping_GetAndDeleteAuthorizeAgainstTheMappingARN(t *testing.T) {
|
||||
const uuid = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"
|
||||
mapping := "arn:aws:lambda:" + gwtest.Region + ":" + gwtest.AccountID + ":event-source-mapping:" + uuid
|
||||
e, store, _ := newMappingEnv(t, gwtest.Statement("Allow", "*", "*"), gwtest.Statement("Deny", "lambda:*", mapping))
|
||||
require.NoError(t, store.Create(t.Context(), eventsource.Mapping{
|
||||
FormatVersion: eventsource.FormatVersion, UUID: uuid, AccountID: gwtest.AccountID, Region: gwtest.Region,
|
||||
FunctionName: "hello", EventSourceARN: queueARN, BatchSize: 1, State: eventsource.StateEnabled,
|
||||
}))
|
||||
_, err := e.do(http.MethodGet, mappingsPath+uuid, nil)
|
||||
gwtest.AssertDenied(t, err)
|
||||
_, err = e.do(http.MethodDelete, mappingsPath+uuid, nil)
|
||||
gwtest.AssertDenied(t, err)
|
||||
m, _, err := store.Get(t.Context(), uuid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, eventsource.StateEnabled, m.State)
|
||||
}
|
||||
@@ -765,9 +765,8 @@ func TestAuthorization(t *testing.T) {
|
||||
|
||||
func TestRegistration_ResolvesActionsAndInventory(t *testing.T) {
|
||||
reg := awsapi.NewRegistration(awsapi.Deps{})
|
||||
assert.Equal(t, []string{"CreateEventSourceMapping", "CreateFunction", "DeleteEventSourceMapping", "GetEventSourceMapping", "GetFunction", "GetFunctionCodeSigningConfig", "GetFunctionConfiguration", "Invoke", "ListEventSourceMappings", "ListVersionsByFunction"}, reg.Inventory.Registered)
|
||||
assert.Equal(t, []string{"CreateFunction", "GetFunction", "GetFunctionCodeSigningConfig", "GetFunctionConfiguration", "Invoke", "ListVersionsByFunction"}, reg.Inventory.Registered)
|
||||
assert.Equal(t, "ListVersionsByFunction", reg.ResolveAction(httptest.NewRequest(http.MethodGet, "/2015-03-31/functions/x/versions", nil)))
|
||||
assert.Equal(t, "DeleteEventSourceMapping", reg.ResolveAction(httptest.NewRequest(http.MethodDelete, "/2015-03-31/event-source-mappings/x", nil)))
|
||||
assert.Equal(t, "GetFunctionCodeSigningConfig", reg.ResolveAction(httptest.NewRequest(http.MethodGet, "/2020-06-30/functions/x/code-signing-config", nil)))
|
||||
assert.Equal(t, "CreateFunction", reg.ResolveAction(httptest.NewRequest(http.MethodPost, "/2015-03-31/functions", nil)))
|
||||
assert.Equal(t, "GetFunction", reg.ResolveAction(httptest.NewRequest(http.MethodGet, "/2015-03-31/functions/x", nil)))
|
||||
|
||||
@@ -114,32 +114,16 @@ func (d Deps) invoke(ctx context.Context, c *call) (reply, error) {
|
||||
if err != nil {
|
||||
return reply{}, err
|
||||
}
|
||||
out, err := d.runSync(ctx, rec, revision, c.Invoke.Qualifier, c.Body)
|
||||
if err != nil {
|
||||
return reply{}, err
|
||||
}
|
||||
hdr := http.Header{}
|
||||
hdr.Set("X-Amz-Executed-Version", out.ExecutedVersion)
|
||||
if out.FunctionError != "" {
|
||||
hdr.Set("X-Amz-Function-Error", out.FunctionError)
|
||||
}
|
||||
return reply{Status: http.StatusOK, Body: rawJSON(out.Payload), Header: hdr}, nil
|
||||
}
|
||||
|
||||
// runSync runs one synchronous attempt on a resolved record and classifies
|
||||
// its result; every error is the AWS error Invoke returns. A nil error means
|
||||
// the result is valid, with or without a function error.
|
||||
func (d Deps) runSync(ctx context.Context, rec function.Record, revision uint64, qualifier string, payload []byte) (SyncResult, error) {
|
||||
if rec.State != function.StateActive {
|
||||
return SyncResult{}, awserrors.Errorf(awserrors.ErrorResourceConflictException,
|
||||
return reply{}, awserrors.Errorf(awserrors.ErrorResourceConflictException,
|
||||
"The operation cannot be performed at this time. The function is currently in the following state: %s", rec.State)
|
||||
}
|
||||
if d.Exec == nil || rec.Code.State != function.CodeStored {
|
||||
return SyncResult{}, serviceError("function execution is unavailable")
|
||||
return reply{}, serviceError("function execution is unavailable")
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return SyncResult{}, err
|
||||
return reply{}, err
|
||||
}
|
||||
// Once accepted, a disconnecting caller does not cancel the attempt; only
|
||||
// the wait is bounded, and a late result is discarded.
|
||||
@@ -149,37 +133,31 @@ func (d Deps) runSync(ctx context.Context, rec function.Record, revision uint64,
|
||||
out, err := d.Exec.InvokeSync(actx, SyncInvocation{
|
||||
Function: rec,
|
||||
Revision: revision,
|
||||
Qualifier: qualifier,
|
||||
Payload: payload,
|
||||
Qualifier: c.Invoke.Qualifier,
|
||||
Payload: c.Body,
|
||||
WaitUntil: waitUntil,
|
||||
})
|
||||
if refusal, ok := errors.AsType[*RefusalError](err); ok {
|
||||
if _, coded := awserrors.ResolveErrorCode(refusal.Err); coded {
|
||||
return SyncResult{}, refusal.Err
|
||||
return reply{}, refusal.Err
|
||||
}
|
||||
err = refusal
|
||||
}
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Lambda: synchronous attempt outcome unknown", "function_id", rec.FunctionID, "err", err)
|
||||
return SyncResult{}, serviceError("the invocation outcome is unknown")
|
||||
return reply{}, serviceError("the invocation outcome is unknown")
|
||||
}
|
||||
if out.ExecutedVersion == "" || len(out.Payload) > maxSyncPayload {
|
||||
slog.ErrorContext(ctx, "Lambda: executor returned an invalid result", "function_id", rec.FunctionID,
|
||||
"payload_bytes", len(out.Payload))
|
||||
return SyncResult{}, serviceError("the invocation outcome is unknown")
|
||||
return reply{}, serviceError("the invocation outcome is unknown")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InvokeEventSource runs one event-source batch on $LATEST through the same
|
||||
// state, code and result checks as Invoke, without Invoke's IAM check: the
|
||||
// mapping was authorized when it was created.
|
||||
func (d Deps) InvokeEventSource(ctx context.Context, accountID, region, name string, payload []byte) (SyncResult, error) {
|
||||
rec, revision, err := d.Meta.Get(ctx, accountID, region, name)
|
||||
if err != nil {
|
||||
return SyncResult{}, err
|
||||
hdr := http.Header{}
|
||||
hdr.Set("X-Amz-Executed-Version", out.ExecutedVersion)
|
||||
if out.FunctionError != "" {
|
||||
hdr.Set("X-Amz-Function-Error", out.FunctionError)
|
||||
}
|
||||
return d.runSync(ctx, rec, revision, "", payload)
|
||||
return reply{Status: http.StatusOK, Body: rawJSON(out.Payload), Header: hdr}, nil
|
||||
}
|
||||
|
||||
// checkInvokeOptions refuses every request option this build does not
|
||||
|
||||
@@ -54,12 +54,6 @@ type Deps struct {
|
||||
AEAD cipher.AEAD
|
||||
// Exec runs synchronous attempts; nil refuses every Invoke.
|
||||
Exec Executor
|
||||
// Mappings opens the event-source mapping store; nil refuses every
|
||||
// mapping action.
|
||||
Mappings func(ctx context.Context) (MappingStore, error)
|
||||
// Sources reads a queue as a function's execution role; nil refuses
|
||||
// CreateEventSourceMapping.
|
||||
Sources SourceChecker
|
||||
}
|
||||
|
||||
// NewRegistration returns the Lambda REST-JSON registration.
|
||||
@@ -142,8 +136,6 @@ func (d Deps) serve(w http.ResponseWriter, inv dispatch.Invocation) error {
|
||||
if name, _, err := resolveFunctionName(inv.Region, inv.AccountID, params[0]); err == nil {
|
||||
resource = functionARN(inv.Region, inv.AccountID, name)
|
||||
}
|
||||
case "GetEventSourceMapping", "DeleteEventSourceMapping":
|
||||
resource = mappingARN(inv.Region, inv.AccountID, params[0])
|
||||
case "Invoke":
|
||||
// Invoke validates deterministically before authorizing, and its
|
||||
// resource comes from that same validated target. URL.Query drops
|
||||
@@ -168,7 +160,8 @@ func (d Deps) serve(w http.ResponseWriter, inv dispatch.Invocation) error {
|
||||
}
|
||||
}
|
||||
// Only CreateFunction and GetFunction touch the code store.
|
||||
needsCodes := action == "CreateFunction" || action == "GetFunction"
|
||||
needsCodes := action != "GetFunctionConfiguration" && action != "GetFunctionCodeSigningConfig" &&
|
||||
action != "ListVersionsByFunction" && action != "Invoke"
|
||||
if d.Meta == nil || (d.Codes == nil && needsCodes) {
|
||||
return errors.New(awserrors.ErrorServiceException)
|
||||
}
|
||||
|
||||
@@ -66,30 +66,6 @@ var routes = []route{
|
||||
Handler: func(ctx context.Context, d Deps, c *call) (reply, error) {
|
||||
return d.invoke(ctx, c)
|
||||
}},
|
||||
{Method: "POST", Pattern: "/2015-03-31/event-source-mappings/", Action: "CreateEventSourceMapping",
|
||||
Handler: func(ctx context.Context, d Deps, c *call) (reply, error) {
|
||||
return d.createEventSourceMapping(ctx, c)
|
||||
}},
|
||||
{Method: "POST", Pattern: "/2015-03-31/event-source-mappings", Action: "CreateEventSourceMapping",
|
||||
Handler: func(ctx context.Context, d Deps, c *call) (reply, error) {
|
||||
return d.createEventSourceMapping(ctx, c)
|
||||
}},
|
||||
{Method: "GET", Pattern: "/2015-03-31/event-source-mappings/", Action: "ListEventSourceMappings",
|
||||
Handler: func(ctx context.Context, d Deps, c *call) (reply, error) {
|
||||
return d.listEventSourceMappings(ctx, c)
|
||||
}},
|
||||
{Method: "GET", Pattern: "/2015-03-31/event-source-mappings", Action: "ListEventSourceMappings",
|
||||
Handler: func(ctx context.Context, d Deps, c *call) (reply, error) {
|
||||
return d.listEventSourceMappings(ctx, c)
|
||||
}},
|
||||
{Method: "GET", Pattern: "/2015-03-31/event-source-mappings/{UUID}", Action: "GetEventSourceMapping",
|
||||
Handler: func(ctx context.Context, d Deps, c *call) (reply, error) {
|
||||
return d.getEventSourceMapping(ctx, c)
|
||||
}},
|
||||
{Method: "DELETE", Pattern: "/2015-03-31/event-source-mappings/{UUID}", Action: "DeleteEventSourceMapping",
|
||||
Handler: func(ctx context.Context, d Deps, c *call) (reply, error) {
|
||||
return d.deleteEventSourceMapping(ctx, c)
|
||||
}},
|
||||
}
|
||||
|
||||
var router = rest.NewRouter("lambda", routes)
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
// Package eventsource holds the Lambda event-source mapping record and its
|
||||
// JetStream KV store.
|
||||
package eventsource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// FormatVersion is the only record layout this build reads or writes.
|
||||
const FormatVersion = 1
|
||||
|
||||
// KVBucket holds every mapping, keyed by UUID; the UUID is global in AWS.
|
||||
const KVBucket = "lambda-event-source-mappings"
|
||||
|
||||
// Mapping states this build records. A mapping is Enabled from creation and
|
||||
// Deleting from the delete request until its poller has stopped.
|
||||
const (
|
||||
StateEnabled = "Enabled"
|
||||
StateDeleting = "Deleting"
|
||||
)
|
||||
|
||||
// ReasonUserInitiated is the transition reason of a state the caller asked for.
|
||||
const ReasonUserInitiated = "USER_INITIATED"
|
||||
|
||||
// ErrNotFound is returned for a UUID with no mapping.
|
||||
var ErrNotFound = errors.New("eventsource: mapping not found")
|
||||
|
||||
// ErrConflict is returned when the record changed since it was read.
|
||||
var ErrConflict = errors.New("eventsource: mapping changed concurrently")
|
||||
|
||||
// Mapping is one SQS event-source mapping.
|
||||
type Mapping struct {
|
||||
FormatVersion int `json:"formatVersion"`
|
||||
UUID string `json:"uuid"`
|
||||
AccountID string `json:"accountId"`
|
||||
Region string `json:"region"`
|
||||
FunctionName string `json:"functionName"`
|
||||
FunctionARN string `json:"functionArn"`
|
||||
EventSourceARN string `json:"eventSourceArn"`
|
||||
BatchSize int64 `json:"batchSize"`
|
||||
State string `json:"state"`
|
||||
// StateTransitionReason is USER_INITIATED, or the last receive failure
|
||||
// prefixed "PROBLEM: " while polling cannot proceed.
|
||||
StateTransitionReason string `json:"stateTransitionReason"`
|
||||
LastModified time.Time `json:"lastModified"`
|
||||
}
|
||||
|
||||
// Store reads and writes mappings in KVBucket.
|
||||
type Store struct{ kv jetstream.KeyValue }
|
||||
|
||||
// Open returns the store, creating its bucket on first use.
|
||||
func Open(ctx context.Context, js jetstream.KeyValueManager) (*Store, error) {
|
||||
kv, err := kvutil.GetOrCreateBucket(ctx, js, KVBucket, 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("eventsource: open bucket: %w", err)
|
||||
}
|
||||
return &Store{kv: kv}, nil
|
||||
}
|
||||
|
||||
func key(uuid string) string { return "esm." + uuid }
|
||||
|
||||
// Create writes a new mapping; a UUID already present is a conflict.
|
||||
func (s *Store) Create(ctx context.Context, m Mapping) error {
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.kv.Create(ctx, key(m.UUID), b); err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return ErrConflict
|
||||
}
|
||||
return fmt.Errorf("eventsource: create: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the mapping and its KV revision.
|
||||
func (s *Store) Get(ctx context.Context, uuid string) (Mapping, uint64, error) {
|
||||
e, err := s.kv.Get(ctx, key(uuid))
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return Mapping{}, 0, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Mapping{}, 0, fmt.Errorf("eventsource: get: %w", err)
|
||||
}
|
||||
m, err := decode(e.Value())
|
||||
return m, e.Revision(), err
|
||||
}
|
||||
|
||||
// Entry is one listed mapping with the revision it was read at.
|
||||
type Entry struct {
|
||||
Mapping Mapping
|
||||
Revision uint64
|
||||
}
|
||||
|
||||
// List returns every mapping in the bucket once.
|
||||
func (s *Store) List(ctx context.Context) ([]Entry, error) {
|
||||
lister, err := s.kv.ListKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("eventsource: list: %w", err)
|
||||
}
|
||||
defer func() { _ = lister.Stop() }()
|
||||
var out []Entry
|
||||
// A KV listing may repeat a key that changes while it is being listed.
|
||||
seen := map[string]bool{}
|
||||
for k := range lister.Keys() {
|
||||
if seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
e, err := s.kv.Get(ctx, k)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("eventsource: list get: %w", err)
|
||||
}
|
||||
m, err := decode(e.Value())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, Entry{Mapping: m, Revision: e.Revision()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Update replaces the mapping only if it is still at revision.
|
||||
func (s *Store) Update(ctx context.Context, m Mapping, revision uint64) (uint64, error) {
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rev, err := s.kv.Update(ctx, key(m.UUID), b, revision)
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return 0, ErrConflict
|
||||
}
|
||||
return 0, fmt.Errorf("eventsource: update: %w", err)
|
||||
}
|
||||
return rev, nil
|
||||
}
|
||||
|
||||
// Remove deletes the mapping only if it is still at revision.
|
||||
func (s *Store) Remove(ctx context.Context, uuid string, revision uint64) error {
|
||||
if err := s.kv.Delete(ctx, key(uuid), jetstream.LastRevision(revision)); err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return ErrConflict
|
||||
}
|
||||
return fmt.Errorf("eventsource: remove: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decode(b []byte) (Mapping, error) {
|
||||
var m Mapping
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return Mapping{}, fmt.Errorf("eventsource: decode: %w", err)
|
||||
}
|
||||
if m.FormatVersion != FormatVersion {
|
||||
return Mapping{}, fmt.Errorf("eventsource: record format %d is not %d", m.FormatVersion, FormatVersion)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package eventsource_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
testutil "github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/eventsource"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStore_RevisionsGuardEveryChange(t *testing.T) {
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
s, err := eventsource.Open(t.Context(), js)
|
||||
require.NoError(t, err)
|
||||
|
||||
entries, err := s.List(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries)
|
||||
|
||||
m := eventsource.Mapping{FormatVersion: eventsource.FormatVersion, UUID: "u1", State: eventsource.StateEnabled}
|
||||
require.NoError(t, s.Create(t.Context(), m))
|
||||
require.ErrorIs(t, s.Create(t.Context(), m), eventsource.ErrConflict)
|
||||
|
||||
got, rev, err := s.Get(t.Context(), "u1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, m, got)
|
||||
|
||||
m.State = eventsource.StateDeleting
|
||||
newRev, err := s.Update(t.Context(), m, rev)
|
||||
require.NoError(t, err)
|
||||
_, err = s.Update(t.Context(), m, rev)
|
||||
require.ErrorIs(t, err, eventsource.ErrConflict, "a stale revision never overwrites")
|
||||
require.ErrorIs(t, s.Remove(t.Context(), "u1", rev), eventsource.ErrConflict, "a stale revision never removes")
|
||||
|
||||
require.NoError(t, s.Remove(t.Context(), "u1", newRev))
|
||||
_, _, err = s.Get(t.Context(), "u1")
|
||||
require.ErrorIs(t, err, eventsource.ErrNotFound)
|
||||
entries, err = s.List(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
package function
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Readiness reports why a function whose code is stored cannot run yet, or nil
|
||||
// when it can. Its error text becomes the function's StateReason.
|
||||
type Readiness func(ctx context.Context, rec Record) error
|
||||
|
||||
// SetReadiness enables activation. Without it no function ever leaves Pending.
|
||||
func (s *MetaService) SetReadiness(r Readiness) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.ready = r
|
||||
if s.nudge == nil {
|
||||
s.nudge = make(chan struct{}, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MetaService) readiness() (Readiness, chan struct{}) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.ready, s.nudge
|
||||
}
|
||||
|
||||
// wake asks the activation loop for an early pass; it never blocks.
|
||||
func (s *MetaService) wake() {
|
||||
if _, nudge := s.readiness(); nudge != nil {
|
||||
select {
|
||||
case nudge <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunActivation activates pending functions until ctx ends: once at start, on
|
||||
// every code-stored completion, and every interval, so a crash between storing
|
||||
// the code and activating it is converged by the next pass.
|
||||
func (s *MetaService) RunActivation(ctx context.Context, interval time.Duration) {
|
||||
ready, nudge := s.readiness()
|
||||
if ready == nil {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
if err := s.ActivatePending(ctx); err != nil && ctx.Err() == nil {
|
||||
slog.Warn("Lambda: activation pass incomplete; retrying", "err", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-nudge:
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ActivatePending makes one pass over every account's functions, moving each
|
||||
// Pending function with stored code to Active when readiness allows it.
|
||||
func (s *MetaService) ActivatePending(ctx context.Context) error {
|
||||
ready, _ := s.readiness()
|
||||
if ready == nil {
|
||||
return nil
|
||||
}
|
||||
var accounts []string
|
||||
names := s.js.KeyValueStoreNames(ctx)
|
||||
for name := range names.Name() {
|
||||
if id, ok := strings.CutPrefix(name, KVBucketAccountPrefix); ok {
|
||||
accounts = append(accounts, id)
|
||||
}
|
||||
}
|
||||
if err := names.Error(); err != nil {
|
||||
return fmt.Errorf("lambda: list account buckets: %w", err)
|
||||
}
|
||||
var errs []error
|
||||
for _, accountID := range accounts {
|
||||
if err := s.activateAccount(ctx, accountID, ready); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (s *MetaService) activateAccount(ctx context.Context, accountID string, ready Readiness) error {
|
||||
kv, err := s.bucket(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys, err := kv.ListKeysFiltered(ctx, kvFunctionKeyPrefix+">")
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("lambda: list functions of account %s: %w", accountID, err)
|
||||
}
|
||||
var errs []error
|
||||
for key := range keys.Keys() {
|
||||
if err := s.activateOne(ctx, kv, key, ready); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// activateOne writes the readiness outcome by compare-and-swap at the revision
|
||||
// it read, so a concurrent update or delete wins and is never overwritten.
|
||||
func (s *MetaService) activateOne(ctx context.Context, kv jetstream.KeyValue, key string, ready Readiness) error {
|
||||
entry, err := kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("lambda: read %s: %w", key, err)
|
||||
}
|
||||
rec, err := decode(entry.Value())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rec.State != StatePending || rec.Code.State != CodeStored {
|
||||
return nil
|
||||
}
|
||||
if notReady := ready(ctx, rec); notReady != nil {
|
||||
if rec.StateReason == notReady.Error() {
|
||||
return nil
|
||||
}
|
||||
rec.StateReasonCode, rec.StateReason = ReasonCodeCreating, notReady.Error()
|
||||
} else {
|
||||
rec.State, rec.StateReasonCode, rec.StateReason = StateActive, "", ""
|
||||
}
|
||||
data, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := kv.Update(ctx, key, data, entry.Revision()); err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("lambda: write readiness of %s: %w", key, err)
|
||||
}
|
||||
if rec.State == StateActive {
|
||||
slog.Info("Lambda: function active", "account_id", rec.AccountID, "region", rec.Region,
|
||||
"function_name", rec.FunctionName, "function_id", rec.FunctionID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package function_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/function"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
actAccount = "000000000001"
|
||||
actRegion = "ap-southeast-2"
|
||||
actDigest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
)
|
||||
|
||||
func newOwner(t *testing.T) *function.MetaService {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
return function.NewMetaService(testutil.NewJetStream(t, nc))
|
||||
}
|
||||
|
||||
func create(t *testing.T, svc *function.MetaService, name string) uint64 {
|
||||
t.Helper()
|
||||
resp, err := svc.Create(t.Context(), &function.CreateRequest{Region: actRegion, Record: function.Record{
|
||||
FunctionID: "id-" + name, FunctionName: name, Role: "arn:aws:iam::000000000001:role/r",
|
||||
Runtime: "provided.al2023", Handler: "bootstrap", Timeout: 3, MemorySize: 128,
|
||||
Code: function.Code{RevisionID: "rev-" + name},
|
||||
}}, actAccount)
|
||||
require.NoError(t, err)
|
||||
return resp.Revision
|
||||
}
|
||||
|
||||
func get(t *testing.T, svc *function.MetaService, name string) *function.GetResponse {
|
||||
t.Helper()
|
||||
resp, err := svc.Get(t.Context(), &function.GetRequest{Region: actRegion, FunctionName: name}, actAccount)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
|
||||
func storeCode(t *testing.T, svc *function.MetaService, name string, rev uint64) {
|
||||
t.Helper()
|
||||
_, err := svc.CodeStored(t.Context(), &function.CodeStoredRequest{Region: actRegion, FunctionName: name, Revision: rev, SHA256: actDigest, Size: 9}, actAccount)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestActivation_WithoutReadinessNothingLeavesPending(t *testing.T) {
|
||||
svc := newOwner(t)
|
||||
storeCode(t, svc, "hello", create(t, svc, "hello"))
|
||||
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
svc.RunActivation(ctx, time.Millisecond)
|
||||
|
||||
got := get(t, svc, "hello").Record
|
||||
assert.Equal(t, function.StatePending, got.State)
|
||||
assert.Equal(t, function.ReasonCreating, got.StateReason)
|
||||
}
|
||||
|
||||
func TestActivation_UnmetConditionStaysPendingWithItsReason(t *testing.T) {
|
||||
svc := newOwner(t)
|
||||
storeCode(t, svc, "hello", create(t, svc, "hello"))
|
||||
svc.SetReadiness(func(context.Context, function.Record) error { return errors.New("The node is not serving.") })
|
||||
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
first := get(t, svc, "hello")
|
||||
assert.Equal(t, function.StatePending, first.Record.State)
|
||||
assert.Equal(t, function.ReasonCodeCreating, first.Record.StateReasonCode)
|
||||
assert.Equal(t, "The node is not serving.", first.Record.StateReason)
|
||||
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
assert.Equal(t, first.Revision, get(t, svc, "hello").Revision, "an unchanged reason is not rewritten")
|
||||
}
|
||||
|
||||
func TestActivation_ReadyStoredFunctionBecomesActive(t *testing.T) {
|
||||
svc := newOwner(t)
|
||||
storeCode(t, svc, "hello", create(t, svc, "hello"))
|
||||
storingRev := create(t, svc, "storing")
|
||||
var seen []string
|
||||
svc.SetReadiness(func(_ context.Context, rec function.Record) error {
|
||||
seen = append(seen, rec.FunctionName)
|
||||
return nil
|
||||
})
|
||||
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
|
||||
got := get(t, svc, "hello").Record
|
||||
assert.Equal(t, function.StateActive, got.State)
|
||||
assert.Empty(t, got.StateReason)
|
||||
assert.Empty(t, got.StateReasonCode)
|
||||
assert.Equal(t, function.CodeStored, got.Code.State)
|
||||
storing := get(t, svc, "storing")
|
||||
assert.Equal(t, function.StatePending, storing.Record.State, "a function still storing its code is never activated")
|
||||
assert.Equal(t, storingRev, storing.Revision)
|
||||
assert.Equal(t, []string{"hello"}, seen)
|
||||
}
|
||||
|
||||
func TestActivation_CodeStoredWakesTheLoop(t *testing.T) {
|
||||
svc := newOwner(t)
|
||||
storeCode(t, svc, "first", create(t, svc, "first"))
|
||||
// Readiness is enabled after first is stored, so no wake is buffered and
|
||||
// only the start pass can activate first.
|
||||
svc.SetReadiness(func(context.Context, function.Record) error { return nil })
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); svc.RunActivation(ctx, time.Hour) }()
|
||||
t.Cleanup(func() { cancel(); <-done })
|
||||
require.Eventually(t, func() bool { return get(t, svc, "first").Record.State == function.StateActive },
|
||||
10*time.Second, 20*time.Millisecond, "the start pass activates what was already stored")
|
||||
|
||||
storeCode(t, svc, "hello", create(t, svc, "hello"))
|
||||
|
||||
require.Eventually(t, func() bool { return get(t, svc, "hello").Record.State == function.StateActive },
|
||||
10*time.Second, 20*time.Millisecond, "after the start pass, only the code-stored wake can activate within the hour")
|
||||
}
|
||||
@@ -44,8 +44,6 @@ type MetaService struct {
|
||||
|
||||
mu sync.Mutex
|
||||
buckets map[string]jetstream.KeyValue
|
||||
ready Readiness
|
||||
nudge chan struct{}
|
||||
}
|
||||
|
||||
// NewMetaService builds the service over a JetStream handle.
|
||||
@@ -308,7 +306,6 @@ func (s *MetaService) CodeStored(ctx context.Context, req *CodeStoredRequest, ac
|
||||
return nil, fmt.Errorf("lambda: update function record: %w", err)
|
||||
}
|
||||
attach(ctx, kv, up, upRev, rev)
|
||||
s.wake()
|
||||
return &CodeStoredResponse{Record: rec, Revision: rev}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -360,8 +360,6 @@ func (f *fakeInstanceLauncher) TerminateSystemInstance(instanceID string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeInstanceLauncher) RebootSystemInstance(string) error { return nil }
|
||||
|
||||
// fakeAMIResolver always resolves the same serving AMI.
|
||||
type fakeAMIResolver struct{}
|
||||
|
||||
|
||||
@@ -66,12 +66,6 @@ func (s *Service) DeleteDBInstance(ctx context.Context, input *rds.DeleteDBInsta
|
||||
"DB instance %s is already being deleted with %s; a retry must repeat that choice", id, snapshotChoice(rec.FinalSnapshotIdentifier))
|
||||
}
|
||||
|
||||
// The handler that accepted the delete tears it down, and the reconciler
|
||||
// resumes one whose handler is gone, so a repeat only reports the state.
|
||||
if rec.Status == StatusDeleting {
|
||||
return &rds.DeleteDBInstanceOutput{DBInstance: s.projectDBInstance(rec)}, nil
|
||||
}
|
||||
|
||||
// Reserve the final snapshot before the instance enters deleting. A collision
|
||||
// therefore fails while the VM and data volume are still intact, and a crash
|
||||
// afterwards leaves a creating record the reconciler can resolve.
|
||||
@@ -80,29 +74,29 @@ func (s *Service) DeleteDBInstance(ctx context.Context, input *rds.DeleteDBInsta
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
rec.Status = StatusDeleting
|
||||
rec.FailureReason = ""
|
||||
rec.FinalSnapshotIdentifier = finalSnapshot
|
||||
rec.TransitionStartedAt = &now
|
||||
rec.UpdatedAt = now
|
||||
if err := updateJSON(ctx, kv, DBInstanceKey(id), rev, rec); err != nil {
|
||||
if reservation.creator {
|
||||
s.rollbackFinalSnapshotReservation(ctx, kv, finalSnapshot, reservation.revision)
|
||||
// A repeat of a delete already under way re-runs the teardown rather than
|
||||
// being rejected: the first call may have died partway through it.
|
||||
if rec.Status != StatusDeleting {
|
||||
now := time.Now().UTC()
|
||||
rec.Status = StatusDeleting
|
||||
rec.FailureReason = ""
|
||||
rec.FinalSnapshotIdentifier = finalSnapshot
|
||||
rec.TransitionStartedAt = &now
|
||||
rec.UpdatedAt = now
|
||||
if err := updateJSON(ctx, kv, DBInstanceKey(id), rev, rec); err != nil {
|
||||
if reservation.creator {
|
||||
s.rollbackFinalSnapshotReservation(ctx, kv, finalSnapshot, reservation.revision)
|
||||
}
|
||||
if errors.Is(err, kvstore.ErrConflict) {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorDBInstanceInvalidState,
|
||||
"DB instance %s changed state concurrently; retry the delete", id)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if errors.Is(err, kvstore.ErrConflict) {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorDBInstanceInvalidState,
|
||||
"DB instance %s changed state concurrently; retry the delete", id)
|
||||
}
|
||||
return nil, err
|
||||
s.RecordEvent(ctx, accountID, EventSourceTypeDBInstance, id, "DB instance deleted.", EventCategoryDeletion)
|
||||
}
|
||||
s.RecordEvent(ctx, accountID, EventSourceTypeDBInstance, id, "DB instance deleted.", EventCategoryDeletion)
|
||||
|
||||
// Ends exactly when the reconciler's guard does, so the teardown is this
|
||||
// handler's alone until the reconciler may resume it.
|
||||
teardownCtx, cancel := context.WithDeadline(ctx, deleteClaimEnds(rec))
|
||||
defer cancel()
|
||||
if err := s.teardownDBInstance(teardownCtx, kv, accountID, rec, reservation.creator); err != nil {
|
||||
if err := s.teardownDBInstance(ctx, kv, accountID, rec, reservation.creator); err != nil {
|
||||
// The reconciler finishes the delete once the modify lets go.
|
||||
if errors.Is(err, errModifyInProgress) {
|
||||
return &rds.DeleteDBInstanceOutput{DBInstance: s.projectDBInstance(rec)}, nil
|
||||
@@ -208,11 +202,6 @@ func (s *Service) teardownDBInstance(ctx context.Context, kv *kvstore.Bucket, ac
|
||||
s.stopEngineOrRecordFallback(ctx, accountID, rec, "deleting")
|
||||
}
|
||||
|
||||
// The engine stop swallows a deadline, and a handler past its claim must not
|
||||
// start a terminate the reconciler may already be running.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.terminateInstanceVM(ctx, rec.InstanceID, rec.DataVolumeID); err != nil {
|
||||
return fmt.Errorf("rds: terminate the VM behind %s: %w", rec.DBInstanceIdentifier, err)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/rds"
|
||||
@@ -178,12 +176,13 @@ func TestDeleteDBInstance_ReservesTheFinalSnapshotBeforeCuttingIt(t *testing.T)
|
||||
}
|
||||
|
||||
// A dead worker can leave the EC2 snapshot cut while its RDS record still says
|
||||
// creating. The resumed teardown adopts that exact account-scoped snapshot
|
||||
// instead of cutting another one and permanently pinning the source volume twice.
|
||||
func TestReconciler_AdoptsAnInterruptedFinalSnapshot(t *testing.T) {
|
||||
// creating. A retry adopts that exact account-scoped snapshot instead of
|
||||
// cutting another one and permanently pinning the source volume twice.
|
||||
func TestDeleteDBInstance_AdoptsAnInterruptedFinalSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newLifecycleHarness(t, false)
|
||||
rec := abandonedDelete(availableRecord())
|
||||
rec := availableRecord()
|
||||
rec.Status = StatusDeleting
|
||||
rec.FinalSnapshotIdentifier = "orders-db-final"
|
||||
seedInstance(t, h.svc, rec)
|
||||
|
||||
@@ -202,9 +201,13 @@ func TestReconciler_AdoptsAnInterruptedFinalSnapshot(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, onePass(t, NewReconciler(h.svc, "node-a")))
|
||||
_, err = h.svc.DeleteDBInstance(t.Context(), &rds.DeleteDBInstanceInput{
|
||||
DBInstanceIdentifier: aws.String(testDBID),
|
||||
FinalDBSnapshotIdentifier: aws.String(rec.FinalSnapshotIdentifier),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, h.snaps.created, "the resumed teardown must adopt the cut snapshot, not create another")
|
||||
assert.Empty(t, h.snaps.created, "the retry must adopt the cut snapshot, not create another")
|
||||
stored, found := h.snapshotRecord(t, rec.FinalSnapshotIdentifier)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, SnapshotStatusAvailable, stored.Status)
|
||||
@@ -322,18 +325,26 @@ func TestDeleteDBInstance_RetainsTheVolumeWhenTheVolumeStoreReportsItInUse(t *te
|
||||
|
||||
// A teardown that died partway through is replayed, so every step has to treat
|
||||
// a resource that is already gone as work it has done.
|
||||
func TestReconciler_IsIdempotentAcrossARetriedTeardown(t *testing.T) {
|
||||
func TestDeleteDBInstance_IsIdempotentAcrossARetriedTeardown(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newLifecycleHarness(t, false)
|
||||
rec := abandonedDelete(availableRecord())
|
||||
rec := availableRecord()
|
||||
// The shape a delete leaves behind when its caller died mid-teardown.
|
||||
rec.Status = StatusDeleting
|
||||
rec.FinalSnapshotIdentifier = "orders-db-final"
|
||||
seedInstance(t, h.svc, rec)
|
||||
reconciler := NewReconciler(h.svc, "node-a")
|
||||
require.NoError(t, onePass(t, reconciler))
|
||||
|
||||
input := &rds.DeleteDBInstanceInput{
|
||||
DBInstanceIdentifier: aws.String(testDBID),
|
||||
FinalDBSnapshotIdentifier: aws.String("orders-db-final"),
|
||||
}
|
||||
_, err := h.svc.DeleteDBInstance(t.Context(), input, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The record left behind by the interrupted attempt, replayed.
|
||||
seedInstance(t, h.svc, rec)
|
||||
require.NoError(t, onePass(t, reconciler))
|
||||
_, err = h.svc.DeleteDBInstance(t.Context(), input, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, h.snaps.created, 1, "a replayed teardown must not take a second final snapshot")
|
||||
assert.False(t, h.recordExists(t, testDBID))
|
||||
@@ -344,7 +355,9 @@ func TestReconciler_IsIdempotentAcrossARetriedTeardown(t *testing.T) {
|
||||
func TestReconciler_ResumesAnInterruptedDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newLifecycleHarness(t, false)
|
||||
seedInstance(t, h.svc, abandonedDelete(availableRecord()))
|
||||
rec := availableRecord()
|
||||
rec.Status = StatusDeleting
|
||||
seedInstance(t, h.svc, rec)
|
||||
|
||||
reconciler := NewReconciler(h.svc, "node-a")
|
||||
require.NoError(t, onePass(t, reconciler))
|
||||
@@ -353,87 +366,6 @@ func TestReconciler_ResumesAnInterruptedDelete(t *testing.T) {
|
||||
assert.False(t, h.recordExists(t, testDBID))
|
||||
}
|
||||
|
||||
// A second teardown racing the handler's sees the VM mid-shutdown as gone and
|
||||
// fails on the still-attached volume, so the handler's delete is left to it.
|
||||
func TestReconciler_LeavesADeleteItsHandlerMayStillBeRunning(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newLifecycleHarness(t, false)
|
||||
rec := availableRecord()
|
||||
started := time.Now().UTC()
|
||||
rec.Status = StatusDeleting
|
||||
rec.TransitionStartedAt = &started
|
||||
seedInstance(t, h.svc, rec)
|
||||
|
||||
require.NoError(t, onePass(t, NewReconciler(h.svc, "node-a")))
|
||||
|
||||
assert.Empty(t, h.launcher.terminated)
|
||||
assert.True(t, h.recordExists(t, testDBID))
|
||||
}
|
||||
|
||||
// A repeat must not start a teardown of its own alongside the one under way;
|
||||
// the reconciler is what resumes an abandoned one.
|
||||
func TestDeleteDBInstance_ARepeatStartsNoTeardown(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newLifecycleHarness(t, false)
|
||||
seedInstance(t, h.svc, abandonedDelete(availableRecord()))
|
||||
|
||||
out, err := h.svc.DeleteDBInstance(t.Context(), skipFinalSnapshot(), testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, string(StatusDeleting), aws.StringValue(out.DBInstance.DBInstanceStatus))
|
||||
assert.Empty(t, h.launcher.terminated)
|
||||
assert.True(t, h.recordExists(t, testDBID))
|
||||
}
|
||||
|
||||
// The reconciler resumes a delete from the persisted stamp, so the handler's
|
||||
// teardown must stop at that same instant, however long the acceptance took.
|
||||
func TestDeleteDBInstance_TeardownEndsWhenTheClaimDoes(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newLifecycleHarness(t, false)
|
||||
seedInstance(t, h.svc, availableRecord())
|
||||
var claimEnds time.Time
|
||||
h.snaps.beforeCreate = func() { stored := h.record(t); claimEnds = deleteClaimEnds(&stored) }
|
||||
|
||||
_, err := h.svc.DeleteDBInstance(t.Context(), &rds.DeleteDBInstanceInput{
|
||||
DBInstanceIdentifier: aws.String(testDBID),
|
||||
FinalDBSnapshotIdentifier: aws.String("orders-db-final"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.False(t, claimEnds.IsZero())
|
||||
assert.True(t, h.snaps.deadline.Equal(claimEnds), "deadline %s, claim ends %s", h.snaps.deadline, claimEnds)
|
||||
}
|
||||
|
||||
// A graceful stop that runs into the claim's end is swallowed, so the teardown
|
||||
// itself has to notice and leave the terminate to the reconciler.
|
||||
func TestTeardownDBInstance_StartsNoTerminatePastItsDeadline(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newLifecycleHarness(t, false)
|
||||
h.agent.silenceType(CommandStopEngine)
|
||||
rec := availableRecord()
|
||||
rec.Status = StatusDeleting
|
||||
rec.FinalSnapshotIdentifier = "orders-db-final"
|
||||
seedInstance(t, h.svc, rec)
|
||||
kv, err := h.svc.bucket(t.Context(), testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
err = h.svc.teardownDBInstance(ctx, kv, testAccountID, &rec, true)
|
||||
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
assert.Empty(t, h.launcher.terminated)
|
||||
assert.True(t, h.recordExists(t, testDBID))
|
||||
}
|
||||
|
||||
// The shape a delete leaves once its handler's request budget has run out.
|
||||
func abandonedDelete(rec DBInstanceRecord) DBInstanceRecord {
|
||||
started := time.Now().UTC().Add(-deleteTimeout - time.Minute)
|
||||
rec.Status = StatusDeleting
|
||||
rec.TransitionStartedAt = &started
|
||||
return rec
|
||||
}
|
||||
|
||||
// A stop whose caller died leaves the VM possibly still running, so the stop is
|
||||
// re-issued rather than assumed.
|
||||
func TestReconciler_ResumesAnInterruptedStop(t *testing.T) {
|
||||
|
||||
@@ -117,9 +117,7 @@ func (f *fakeInstanceCommander) DetachVolumeFromStopped(_ context.Context, insta
|
||||
// DescribeSnapshots reports against the data volume, which is what decides
|
||||
// between deleting the volume and retaining it.
|
||||
type fakeSnapshots struct {
|
||||
created []*ec2.CreateSnapshotInput
|
||||
// The context deadline the last CreateSnapshot ran under.
|
||||
deadline time.Time
|
||||
created []*ec2.CreateSnapshotInput
|
||||
holding []string
|
||||
deleted []string
|
||||
createErr error
|
||||
@@ -134,8 +132,7 @@ type fakeSnapshots struct {
|
||||
|
||||
var _ snapshotProvider = (*fakeSnapshots)(nil)
|
||||
|
||||
func (f *fakeSnapshots) CreateSnapshot(ctx context.Context, input *ec2.CreateSnapshotInput, _ string) (*ec2.Snapshot, error) {
|
||||
f.deadline, _ = ctx.Deadline()
|
||||
func (f *fakeSnapshots) CreateSnapshot(_ context.Context, input *ec2.CreateSnapshotInput, _ string) (*ec2.Snapshot, error) {
|
||||
if f.beforeCreate != nil {
|
||||
f.beforeCreate()
|
||||
}
|
||||
|
||||
@@ -610,11 +610,6 @@ func (r *Reconciler) reconcileStopping(ctx context.Context, kv *kvstore.Bucket,
|
||||
// did; only a teardown still stuck past the bound is called failed, which is
|
||||
// what lets the customer retry the delete.
|
||||
func (r *Reconciler) reconcileDeleting(ctx context.Context, kv *kvstore.Bucket, rev uint64, accountID string, rec *DBInstanceRecord) error {
|
||||
// The handler that accepted the delete tears it down until its claim ends; a
|
||||
// second teardown alongside it sees the VM mid-shutdown as gone.
|
||||
if time.Now().Before(deleteClaimEnds(rec)) {
|
||||
return nil
|
||||
}
|
||||
err := r.svc.teardownDBInstance(ctx, kv, accountID, rec, false)
|
||||
if err == nil || errors.Is(err, errFinalSnapshotInProgress) || errors.Is(err, errModifyInProgress) {
|
||||
return nil
|
||||
@@ -800,12 +795,6 @@ func (s *Service) findEC2SnapshotFor(ctx context.Context, accountID, dbSnapshotI
|
||||
// When the transition began. A record written by an older control plane carries
|
||||
// no stamp, so its last write stands in — it is never earlier than the
|
||||
// transition, so the bound cannot be under-counted.
|
||||
// When the handler that accepted a delete gives up its teardown, measured from
|
||||
// the persisted stamp so the handler and the reconciler agree on it.
|
||||
func deleteClaimEnds(rec *DBInstanceRecord) time.Time {
|
||||
return transitionStarted(rec).Add(deleteTimeout)
|
||||
}
|
||||
|
||||
func transitionStarted(rec *DBInstanceRecord) time.Time {
|
||||
if rec.TransitionStartedAt != nil {
|
||||
return *rec.TransitionStartedAt
|
||||
|
||||
@@ -151,9 +151,6 @@ type ControlServer struct {
|
||||
// IOTimeout bounds reading the request and writing the reply.
|
||||
IOTimeout time.Duration
|
||||
Handle func(ctx context.Context, req []byte, cred Credential) Reply
|
||||
// AcceptStopped, if set, runs once Serve stops accepting, before it waits
|
||||
// for the connections it started.
|
||||
AcceptStopped func()
|
||||
}
|
||||
|
||||
// Serve accepts until ctx ends or the listener fails, then waits for every
|
||||
@@ -167,9 +164,6 @@ func (s *ControlServer) Serve(ctx context.Context, ln *net.UnixListener) error {
|
||||
defer func() { _ = ln.Close() }()
|
||||
var wg sync.WaitGroup
|
||||
defer wg.Wait()
|
||||
if s.AcceptStopped != nil {
|
||||
defer s.AcceptStopped()
|
||||
}
|
||||
slots := make(chan struct{}, MaxAdmitted)
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -462,45 +462,3 @@ func TestControl_MixedProtocolNamesNeverConnect(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestControl_AcceptStoppedRunsBeforeTheDrain(t *testing.T) {
|
||||
name := controlName(t)
|
||||
ln, err := ListenControl(name)
|
||||
require.NoError(t, err)
|
||||
inHandle, release := make(chan struct{}), make(chan struct{})
|
||||
stopped := make(chan struct{})
|
||||
srv := &ControlServer{GatewayUID: os.Getuid(), RequestLimit: testRequestLimit, ReplyLimit: 4096, IOTimeout: 5 * time.Second,
|
||||
Handle: func(context.Context, []byte, Credential) Reply {
|
||||
close(inHandle)
|
||||
<-release
|
||||
return Reply{Outcome: OutcomeDone}
|
||||
},
|
||||
AcceptStopped: func() { close(stopped) }}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- srv.Serve(context.Background(), ln) }()
|
||||
go func() { _, _ = client(name, os.Getuid()).Exchange(context.Background(), []byte("{}"), testCred, soon()) }()
|
||||
select {
|
||||
case <-inHandle:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the request never reached Handle")
|
||||
}
|
||||
|
||||
require.NoError(t, ln.Close(), "the listener fails under an outstanding invocation")
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("AcceptStopped did not run while the invocation was outstanding")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("Serve returned before its connection finished: %v", err)
|
||||
default:
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case err := <-done:
|
||||
require.Error(t, err, "a listener failure is reported")
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Serve did not return after its connection finished")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,17 +90,10 @@ type Daemon struct {
|
||||
// InitializerSHA256 the digest it must have.
|
||||
Initializer string
|
||||
InitializerSHA256 string
|
||||
// Firecracker, when set, is the Firecracker binary each attempt boots
|
||||
// under instead of QEMU.
|
||||
Firecracker string
|
||||
|
||||
busy atomic.Bool
|
||||
serving atomic.Bool
|
||||
busy atomic.Bool
|
||||
}
|
||||
|
||||
// Serving reports whether the control socket is accepting invocations.
|
||||
func (d *Daemon) Serving() bool { return d.serving.Load() }
|
||||
|
||||
// unresolved names an attempt whose marker is still on disk. A marker goes
|
||||
// only once the attempt's VM stop is proven and everything it held is
|
||||
// released, so it outlives a daemon restart; nothing resolves it on its own.
|
||||
@@ -115,12 +108,6 @@ func (d *Daemon) unresolved() (string, error) {
|
||||
return strings.TrimSuffix(filepath.Base(markers[0]), markerSuffix), nil
|
||||
}
|
||||
|
||||
// validHandler is the handler a guest run accepts: 1..128 bytes, no
|
||||
// whitespace or NUL.
|
||||
func validHandler(h string) bool {
|
||||
return h != "" && len(h) <= 128 && strings.IndexFunc(h, unicode.IsSpace) < 0 && !strings.ContainsRune(h, 0)
|
||||
}
|
||||
|
||||
// controlIOTimeout bounds reading a request from, and writing a reply to, the
|
||||
// gateway on the control socket.
|
||||
const controlIOTimeout = 30 * time.Second
|
||||
@@ -132,24 +119,14 @@ func (d *Daemon) ServeControl(ctx context.Context, name string, gatewayUID int)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.serve(ctx, ln, d.controlServer(gatewayUID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// controlServer answers the gateway with d.Handle, and stops d reporting
|
||||
// serving as soon as it stops accepting, before its connections drain.
|
||||
func (d *Daemon) controlServer(gatewayUID int) *ControlServer {
|
||||
return &ControlServer{GatewayUID: gatewayUID, RequestLimit: MaxFrameBytes, ReplyLimit: MaxFrameBytes,
|
||||
IOTimeout: controlIOTimeout, Handle: d.Handle, AcceptStopped: func() { d.serving.Store(false) }}
|
||||
}
|
||||
|
||||
func (d *Daemon) serve(ctx context.Context, ln *net.UnixListener, srv *ControlServer) {
|
||||
d.serving.Store(true)
|
||||
srv := &ControlServer{GatewayUID: gatewayUID, RequestLimit: MaxFrameBytes, ReplyLimit: MaxFrameBytes,
|
||||
IOTimeout: controlIOTimeout, Handle: d.Handle}
|
||||
go func() {
|
||||
if err := srv.Serve(ctx, ln); err != nil {
|
||||
slog.Error("Lambda test link: control socket stopped; not serving new invocations", "err", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle runs one request under its grant, giving the guest cred in its first
|
||||
@@ -316,7 +293,8 @@ func CheckRequest(req Request) error {
|
||||
if req.RequestID == "" || req.RevisionID == "" {
|
||||
return errors.New("request and revision IDs are required")
|
||||
}
|
||||
if !validHandler(req.Handler) {
|
||||
if req.Handler == "" || len(req.Handler) > 128 || strings.IndexFunc(req.Handler, unicode.IsSpace) >= 0 ||
|
||||
strings.ContainsRune(req.Handler, 0) {
|
||||
return errors.New("handler must be 1..128 characters with no whitespace")
|
||||
}
|
||||
if req.TimeoutMs <= 0 || req.TimeoutMs > maxTimeoutMs {
|
||||
@@ -426,50 +404,24 @@ func (d *Daemon) run(ctx context.Context, req Request, code []byte, lib *os.File
|
||||
}
|
||||
defer func() { _ = ln.Close() }()
|
||||
|
||||
// A launch delayed past the fixed deadline is refused before the VM starts.
|
||||
cmd, err := cfg.Execute()
|
||||
if err != nil {
|
||||
return refused("VM command build failed"), d.release(a), marker
|
||||
}
|
||||
// A launch delayed past the fixed deadline is refused before QEMU starts.
|
||||
if expired(hardDeadline) {
|
||||
return refused(ErrDeadlineExpired.Error()), d.release(a), marker
|
||||
}
|
||||
var cmd *exec.Cmd
|
||||
var hk *hardKill
|
||||
var fcAck *net.UnixConn
|
||||
fc := &vmHandle{fd: -1}
|
||||
if d.Firecracker == "" {
|
||||
if cmd, err = cfg.Execute(); err != nil {
|
||||
return refused("VM command build failed"), d.release(a), marker
|
||||
}
|
||||
if hk, err = armLaunch(cmd, d.Image, lib, hardDeadline); err != nil {
|
||||
return refused("hard-kill setup failed"), d.release(a), marker
|
||||
}
|
||||
defer hk.close()
|
||||
} else {
|
||||
var child *os.File
|
||||
if fcAck, child, err = ackPair(); err != nil {
|
||||
return refused("hard-kill setup failed"), d.release(a), marker
|
||||
}
|
||||
defer func() { _ = fcAck.Close() }()
|
||||
cmd, err = d.supervisorCommand(filepath.Join(a.dir, fcConfigFile), hardDeadline, child)
|
||||
defer func() { _ = child.Close() }()
|
||||
if err != nil {
|
||||
return refused("VM command build failed"), d.release(a), marker
|
||||
}
|
||||
hk, err := armLaunch(cmd, d.Image, lib, hardDeadline)
|
||||
if err != nil {
|
||||
return refused("hard-kill setup failed"), d.release(a), marker
|
||||
}
|
||||
defer hk.close()
|
||||
var result Reply
|
||||
unrecorded := false
|
||||
out, runErr := attempt.Run(ctx, cmd, timeoutFor(req), func(sctx context.Context, pidfd int) error {
|
||||
vmPidfd, vmPID := pidfd, cmd.Process.Pid
|
||||
if fcAck != nil {
|
||||
defer fc.callbackDone()
|
||||
// Firecracker is the VM; its supervisor acks only once both kernel kills are armed.
|
||||
fd, pid, err := awaitFirecracker(sctx, fcAck, cmd.Process.Pid, armWait)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fc.set(fd)
|
||||
vmPidfd, vmPID = fd, pid
|
||||
}
|
||||
// The owner holds the launch identity before the guest is sent anything.
|
||||
l, err := launchIdentity(vmPidfd, vmPID, a.tap)
|
||||
l, err := launchIdentity(pidfd, cmd.Process.Pid, a.tap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -478,20 +430,13 @@ func (d *Daemon) run(ctx context.Context, req Request, code []byte, lib *os.File
|
||||
return fmt.Errorf("%w: %w", ErrLaunchNotRecorded, err)
|
||||
}
|
||||
// No frame reaches the guest until QEMU proves its own kill timer is armed.
|
||||
if hk != nil {
|
||||
if err := hk.await(sctx, cmd.Process.Pid, armWait); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := hk.await(sctx, cmd.Process.Pid, armWait); err != nil {
|
||||
return err
|
||||
}
|
||||
r, err := d.session(sctx, req, gen, ln, a.dir, hardDeadline, d.Grants.Accept)
|
||||
result = r
|
||||
return err
|
||||
})
|
||||
if fcAck != nil {
|
||||
// Always settled, so a received pidfd is closed or handed to the callback.
|
||||
fcGone := settleFirecracker(out.SessionDone, fc, cmd.Process != nil)
|
||||
out.Stopped = out.Stopped && fcGone
|
||||
}
|
||||
if !out.Stopped {
|
||||
slog.Error("Lambda test link: VM stop not proven; marker, tap, address and files held",
|
||||
"attempt", req.AttemptID, "tap", tapName, "err", runErr)
|
||||
@@ -582,22 +527,6 @@ func (d *Daemon) prepare(req Request, code []byte, dir, tap, mac, ip string) (*v
|
||||
entries["test-package-sha256"] = []byte(req.CodeSHA256)
|
||||
entries["test-grant"] = grantEntry(req.Grant)
|
||||
entries["test-handler"] = []byte(req.Handler)
|
||||
netcfgBytes := fmt.Appendf(nil, "NIC0_MAC=%s\nNIC0_CIDR=%s/24\nNIC0_DEFAULT=0\n", mac, ip)
|
||||
if d.Firecracker != "" {
|
||||
archive := make(map[string][]byte, len(entries)+1)
|
||||
for name, value := range entries {
|
||||
archive[lambdav1.FwCfgPrefix+name] = value
|
||||
}
|
||||
archive[fwCfgNetcfg] = netcfgBytes
|
||||
cfgDisk := filepath.Join(dir, configArchive)
|
||||
if err := writeConfigArchive(cfgDisk, archive); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err := writeFirecrackerConfig(filepath.Join(dir, fcConfigFile), diskPath, cfgDisk, tap, mac, int(max(req.MemoryMiB, 128))); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return nil, gen, ln, nil
|
||||
}
|
||||
var fwCfg []vm.FwCfgEntry
|
||||
for name, value := range entries {
|
||||
path := filepath.Join(dir, "fwcfg-"+name)
|
||||
@@ -607,7 +536,7 @@ func (d *Daemon) prepare(req Request, code []byte, dir, tap, mac, ip string) (*v
|
||||
fwCfg = append(fwCfg, vm.FwCfgEntry{Name: lambdav1.FwCfgPrefix + name, File: path})
|
||||
}
|
||||
netcfg := filepath.Join(dir, "netcfg")
|
||||
if err := os.WriteFile(netcfg, netcfgBytes, 0o600); err != nil {
|
||||
if err := os.WriteFile(netcfg, fmt.Appendf(nil, "NIC0_MAC=%s\nNIC0_CIDR=%s/24\nNIC0_DEFAULT=0\n", mac, ip), 0o600); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
fwCfg = append(fwCfg, vm.FwCfgEntry{Name: fwCfgNetcfg, File: netcfg})
|
||||
@@ -659,7 +588,6 @@ func (d *Daemon) session(ctx context.Context, req Request, gen *guestconn.Genera
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
_ = os.Remove(filepath.Join(dir, "fwcfg-"+lambdav1.EntryGuestKey))
|
||||
_ = os.Remove(filepath.Join(dir, configArchive))
|
||||
|
||||
// The function timeout runs on the host from dispatch; the boot allowance
|
||||
// covers startup only, so a guest that stops answering is cut off here.
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
package testlink
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// firecrackerArg0 is the argv[0] that turns the spx binary into the
|
||||
// Firecracker supervisor, so the supervisor is the already-pinned binary.
|
||||
const firecrackerArg0 = "spx-lambda-firecracker"
|
||||
|
||||
// The supervisor inherits the sealed kernel and initramfs as fds 3 and 4,
|
||||
// which it passes on to Firecracker unchanged, and its ack socket as fd 5.
|
||||
const (
|
||||
fcKernelFD = 3
|
||||
fcInitrdFD = 4
|
||||
fcAckFD = 5
|
||||
fcAck = "armed"
|
||||
)
|
||||
|
||||
// firecrackerCmdline is the guest kernel command line; reboot=k lets a guest
|
||||
// reboot end Firecracker, and Firecracker adds the virtio-mmio devices itself.
|
||||
const firecrackerCmdline = "console=ttyS0 quiet loglevel=3 reboot=k panic=1 pci=off"
|
||||
|
||||
const (
|
||||
configArchive = "config.tar"
|
||||
fcConfigFile = "firecracker.json"
|
||||
// fcStopWait bounds proving Firecracker gone after its supervisor stopped.
|
||||
fcStopWait = 5 * time.Second
|
||||
)
|
||||
|
||||
func init() {
|
||||
if filepath.Base(os.Args[0]) == firecrackerArg0 {
|
||||
os.Exit(supervise(os.Args[1:]))
|
||||
}
|
||||
}
|
||||
|
||||
// supervise arms a CLOCK_BOOTTIME SIGKILL on itself at the absolute deadline,
|
||||
// then starts Firecracker with SIGKILL as its parent-death signal, so the
|
||||
// kernel ends both at the deadline even if this process and the daemon stop.
|
||||
func supervise(args []string) int {
|
||||
if len(args) != 3 {
|
||||
return 2
|
||||
}
|
||||
deadline, err := strconv.ParseInt(args[2], 10, 64)
|
||||
if err != nil {
|
||||
return 2
|
||||
}
|
||||
unix.CloseOnExec(fcAckFD)
|
||||
// The parent-death signal follows the forking thread, which never exits.
|
||||
runtime.LockOSThread()
|
||||
if err := armBoottimeKill(time.Duration(deadline)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "firecracker supervisor:", err)
|
||||
return 3
|
||||
}
|
||||
pidfd := -1
|
||||
cmd := exec.Command(args[0], "--no-api", "--config-file", args[1], "--level", "Warning")
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
cmd.ExtraFiles = []*os.File{os.NewFile(fcKernelFD, "kernel"), os.NewFile(fcInitrdFD, "initrd")}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL, PidFD: &pidfd}
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "firecracker supervisor: start:", err)
|
||||
return 4
|
||||
}
|
||||
if err := unix.Sendmsg(fcAckFD, []byte(fcAck), unix.UnixRights(pidfd), nil, 0); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
return 5
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
return exit.ExitCode()
|
||||
}
|
||||
return 6
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// sigevent is the kernel's struct sigevent on 64-bit Linux.
|
||||
type sigevent struct {
|
||||
value uintptr
|
||||
signo int32
|
||||
notify int32
|
||||
_ [48]byte
|
||||
}
|
||||
|
||||
const (
|
||||
sigevSignal = 0
|
||||
timerAbstime = 1
|
||||
)
|
||||
|
||||
// armBoottimeKill arms a POSIX timer that delivers SIGKILL to this process at
|
||||
// the absolute CLOCK_BOOTTIME deadline; a passed deadline fires at once.
|
||||
func armBoottimeKill(deadline time.Duration) error {
|
||||
ev := sigevent{signo: int32(unix.SIGKILL), notify: sigevSignal}
|
||||
var id int32
|
||||
if _, _, e := unix.Syscall(unix.SYS_TIMER_CREATE, unix.CLOCK_BOOTTIME,
|
||||
uintptr(unsafe.Pointer(&ev)), uintptr(unsafe.Pointer(&id))); e != 0 {
|
||||
return fmt.Errorf("timer_create: %w", e)
|
||||
}
|
||||
spec := unix.ItimerSpec{Value: unix.NsecToTimespec(int64(deadline))}
|
||||
if _, _, e := unix.Syscall6(unix.SYS_TIMER_SETTIME, uintptr(id), timerAbstime,
|
||||
uintptr(unsafe.Pointer(&spec)), 0, 0, 0); e != 0 {
|
||||
return fmt.Errorf("timer_settime: %w", e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// supervisorCommand builds the supervisor for one attempt: the spx binary
|
||||
// under firecrackerArg0, the sealed kernel and initramfs, and the ack socket.
|
||||
func (d *Daemon) supervisorCommand(configPath string, deadline time.Duration, ack *os.File) (*exec.Cmd, error) {
|
||||
if d.Image == nil || d.Image.Kernel == nil || d.Image.Initrd == nil {
|
||||
return nil, errors.New("runtime image not prepared")
|
||||
}
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd := exec.Command(self, d.Firecracker, configPath, strconv.FormatInt(int64(deadline), 10))
|
||||
cmd.Args[0] = firecrackerArg0
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
cmd.ExtraFiles = []*os.File{d.Image.Kernel, d.Image.Initrd, ack}
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// ackPair is the socket the supervisor acks on; the daemon keeps one end.
|
||||
func ackPair() (daemonEnd *net.UnixConn, child *os.File, err error) {
|
||||
fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_SEQPACKET|unix.SOCK_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
child = os.NewFile(uintptr(fds[1]), "fc-ack")
|
||||
f := os.NewFile(uintptr(fds[0]), "fc-ack-daemon")
|
||||
c, err := net.FileConn(f)
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
_ = child.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return c.(*net.UnixConn), child, nil
|
||||
}
|
||||
|
||||
// awaitFirecracker reads the supervisor's ack and the pidfd it carries, and
|
||||
// checks that pidfd names a live child of the supervisor.
|
||||
func awaitFirecracker(ctx context.Context, ack *net.UnixConn, supervisor int, wait time.Duration) (pidfd, pid int, err error) {
|
||||
deadline := time.Now().Add(wait)
|
||||
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
|
||||
deadline = d
|
||||
}
|
||||
if err := ack.SetReadDeadline(deadline); err != nil {
|
||||
return -1, 0, err
|
||||
}
|
||||
buf := make([]byte, 16)
|
||||
oob := make([]byte, unix.CmsgSpace(4))
|
||||
n, oobn, _, _, err := ack.ReadMsgUnix(buf, oob)
|
||||
if err != nil {
|
||||
return -1, 0, fmt.Errorf("%w: %w", ErrNotArmed, err)
|
||||
}
|
||||
msgs, err := unix.ParseSocketControlMessage(oob[:oobn])
|
||||
if err != nil || len(msgs) != 1 {
|
||||
return -1, 0, fmt.Errorf("%w: no pidfd in ack", ErrNotArmed)
|
||||
}
|
||||
fds, err := unix.ParseUnixRights(&msgs[0])
|
||||
if err != nil || len(fds) != 1 {
|
||||
return -1, 0, fmt.Errorf("%w: no pidfd in ack", ErrNotArmed)
|
||||
}
|
||||
pidfd = fds[0]
|
||||
unix.CloseOnExec(pidfd)
|
||||
if string(buf[:n]) != fcAck {
|
||||
_ = unix.Close(pidfd)
|
||||
return -1, 0, fmt.Errorf("%w: unexpected ack", ErrNotArmed)
|
||||
}
|
||||
if pid, err = pidfdPID(pidfd); err == nil {
|
||||
err = checkParent(pid, supervisor)
|
||||
}
|
||||
if err != nil {
|
||||
_ = unix.Close(pidfd)
|
||||
return -1, 0, fmt.Errorf("%w: %w", ErrNotArmed, err)
|
||||
}
|
||||
return pidfd, pid, nil
|
||||
}
|
||||
|
||||
// pidfdPID reads the PID a pidfd refers to from its fdinfo.
|
||||
func pidfdPID(pidfd int) (int, error) {
|
||||
b, err := os.ReadFile(fmt.Sprintf("/proc/self/fdinfo/%d", pidfd))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for line := range strings.SplitSeq(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "Pid:"); ok {
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil || pid <= 0 {
|
||||
return 0, fmt.Errorf("pidfd names no live process (%q)", strings.TrimSpace(v))
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
}
|
||||
return 0, errors.New("pidfd fdinfo has no Pid")
|
||||
}
|
||||
|
||||
func checkParent(pid, parent int) error {
|
||||
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for line := range strings.SplitSeq(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "PPid:"); ok {
|
||||
if strings.TrimSpace(v) != strconv.Itoa(parent) {
|
||||
return fmt.Errorf("process %d is not the supervisor's child", pid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("process status has no PPid")
|
||||
}
|
||||
|
||||
// stopFirecracker kills Firecracker through its pidfd and reports whether its
|
||||
// exit was proven; the supervisor's own exit proves nothing about it.
|
||||
func stopFirecracker(pidfd int) bool {
|
||||
if pidfd < 0 {
|
||||
return false
|
||||
}
|
||||
_ = unix.PidfdSendSignal(pidfd, unix.SIGKILL, nil, 0)
|
||||
exited, err := pidfdExited(pidfd, fcStopWait)
|
||||
return err == nil && exited
|
||||
}
|
||||
|
||||
// settleFirecracker reports whether Firecracker is proven gone once Run has
|
||||
// returned. A supervisor that never started launched nothing; otherwise only
|
||||
// Firecracker's own pidfd proves its exit, since the supervisor's says nothing.
|
||||
func settleFirecracker(sessionDone <-chan struct{}, fc *vmHandle, supervisorStarted bool) bool {
|
||||
select {
|
||||
case <-sessionDone:
|
||||
default:
|
||||
// The callback may still receive the pidfd; it closes it, and the attempt is held.
|
||||
fc.abandon()
|
||||
return false
|
||||
}
|
||||
fd := fc.take()
|
||||
if fd < 0 {
|
||||
return !supervisorStarted
|
||||
}
|
||||
defer func() { _ = unix.Close(fd) }()
|
||||
return stopFirecracker(fd)
|
||||
}
|
||||
|
||||
// writeConfigArchive writes entries as a tar archive in the fw_cfg sysfs
|
||||
// layout, by_name/<entry>/raw, which the guest's init extracts.
|
||||
func writeConfigArchive(path string, entries map[string][]byte) error {
|
||||
names := make([]string, 0, len(entries))
|
||||
for name := range entries {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
dirs := map[string]bool{}
|
||||
for _, name := range names {
|
||||
file := "by_name/" + name + "/raw"
|
||||
var parents []string
|
||||
for dir := filepath.Dir(file); dir != "." && !dirs[dir]; dir = filepath.Dir(dir) {
|
||||
parents = append(parents, dir)
|
||||
dirs[dir] = true
|
||||
}
|
||||
for i := len(parents) - 1; i >= 0; i-- {
|
||||
if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeDir, Name: parents[i] + "/", Mode: 0o700}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
b := entries[name]
|
||||
if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeReg, Name: file, Mode: 0o600, Size: int64(len(b))}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, buf.Bytes(), 0o600)
|
||||
}
|
||||
|
||||
type fcConfig struct {
|
||||
BootSource struct {
|
||||
Kernel string `json:"kernel_image_path"`
|
||||
Initrd string `json:"initrd_path"`
|
||||
BootArgs string `json:"boot_args"`
|
||||
} `json:"boot-source"`
|
||||
Drives []fcDrive `json:"drives"`
|
||||
Machine struct {
|
||||
VCPUs int `json:"vcpu_count"`
|
||||
MemMiB int `json:"mem_size_mib"`
|
||||
SMT bool `json:"smt"`
|
||||
} `json:"machine-config"`
|
||||
Net []fcNet `json:"network-interfaces"`
|
||||
}
|
||||
|
||||
type fcDrive struct {
|
||||
ID string `json:"drive_id"`
|
||||
Path string `json:"path_on_host"`
|
||||
Root bool `json:"is_root_device"`
|
||||
ReadOnly bool `json:"is_read_only"`
|
||||
}
|
||||
|
||||
type fcNet struct {
|
||||
ID string `json:"iface_id"`
|
||||
MAC string `json:"guest_mac"`
|
||||
Tap string `json:"host_dev_name"`
|
||||
}
|
||||
|
||||
// writeFirecrackerConfig writes the attempt's Firecracker config: the sealed
|
||||
// kernel and initramfs, the package disk then the configuration disk, and the tap.
|
||||
func writeFirecrackerConfig(path, pkg, cfgDisk, tap, mac string, memMiB int) error {
|
||||
var c fcConfig
|
||||
c.BootSource.Kernel = fmt.Sprintf("/proc/self/fd/%d", fcKernelFD)
|
||||
c.BootSource.Initrd = fmt.Sprintf("/proc/self/fd/%d", fcInitrdFD)
|
||||
c.BootSource.BootArgs = firecrackerCmdline
|
||||
c.Drives = []fcDrive{{ID: "pkg", Path: pkg, ReadOnly: true}, {ID: "cfg", Path: cfgDisk}}
|
||||
c.Machine.VCPUs, c.Machine.MemMiB = 1, memMiB
|
||||
c.Net = []fcNet{{ID: "net0", MAC: mac, Tap: tap}}
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, b, 0o600)
|
||||
}
|
||||
|
||||
// vmHandle owns Firecracker's pidfd across the session callback, which can
|
||||
// still be running when attempt.Run returns; whichever side finishes last
|
||||
// closes an abandoned fd.
|
||||
type vmHandle struct {
|
||||
mu sync.Mutex
|
||||
fd int
|
||||
finished bool
|
||||
abandoned bool
|
||||
}
|
||||
|
||||
func (h *vmHandle) set(fd int) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.fd = fd
|
||||
}
|
||||
|
||||
// callbackDone runs as the callback returns.
|
||||
func (h *vmHandle) callbackDone() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.finished = true
|
||||
h.closeIfAbandoned()
|
||||
}
|
||||
|
||||
// abandon gives up the fd while the callback may still run.
|
||||
func (h *vmHandle) abandon() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.abandoned = true
|
||||
h.closeIfAbandoned()
|
||||
}
|
||||
|
||||
func (h *vmHandle) closeIfAbandoned() {
|
||||
if h.abandoned && h.finished && h.fd >= 0 {
|
||||
_ = unix.Close(h.fd)
|
||||
h.fd = -1
|
||||
}
|
||||
}
|
||||
|
||||
// take hands the fd to teardown once the callback has returned.
|
||||
func (h *vmHandle) take() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
fd := h.fd
|
||||
h.fd = -1
|
||||
return fd
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
//test:in-package drives the unexported supervisor, ack and pidfd helpers against real processes
|
||||
|
||||
package testlink
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/lambda/attempt"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/lambda/guestconn"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// supervised starts this test binary as the Firecracker supervisor over a
|
||||
// stand-in VMM that runs until killed, and returns the supervisor and the
|
||||
// VMM's pidfd from the ack.
|
||||
func supervised(t *testing.T, deadline time.Duration) (*exec.Cmd, int) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
vmm := filepath.Join(dir, "firecracker")
|
||||
require.NoError(t, os.WriteFile(vmm, []byte("#!/bin/sh\nexec tail -f /dev/null\n"), 0o755))
|
||||
kernel, err := os.Open(os.DevNull)
|
||||
require.NoError(t, err)
|
||||
initrd, err := os.Open(os.DevNull)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = kernel.Close(); _ = initrd.Close() })
|
||||
d := &Daemon{Firecracker: vmm, Image: &Image{Kernel: kernel, Initrd: initrd}}
|
||||
ack, child, err := ackPair()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ack.Close() })
|
||||
cmd, err := d.supervisorCommand(filepath.Join(dir, fcConfigFile), deadline, child)
|
||||
require.NoError(t, err)
|
||||
cmd.Path = os.Args[0]
|
||||
cmd.Stdout, cmd.Stderr = io.Discard, io.Discard
|
||||
require.NoError(t, cmd.Start())
|
||||
_ = child.Close()
|
||||
t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
|
||||
|
||||
pidfd, pid, err := awaitFirecracker(context.Background(), ack, cmd.Process.Pid, armWait)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = unix.PidfdSendSignal(pidfd, unix.SIGKILL, nil, 0); _ = unix.Close(pidfd) })
|
||||
require.NotEqual(t, cmd.Process.Pid, pid)
|
||||
return cmd, pidfd
|
||||
}
|
||||
|
||||
func boottime(t *testing.T) time.Duration {
|
||||
t.Helper()
|
||||
now, err := bootNow()
|
||||
require.NoError(t, err)
|
||||
return now
|
||||
}
|
||||
|
||||
func TestFirecrackerSupervisorKillsAtBoottimeDeadlineWhileStopped(t *testing.T) {
|
||||
cmd, pidfd := supervised(t, boottime(t)+1500*time.Millisecond)
|
||||
|
||||
// The kernel holds a SIGKILL timer on CLOCK_BOOTTIME (7), so the deadline counts suspend.
|
||||
timers, err := os.ReadFile("/proc/" + strconv.Itoa(cmd.Process.Pid) + "/timers")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(timers), "signal: 9/")
|
||||
assert.Contains(t, string(timers), "ClockID: 7")
|
||||
|
||||
// A stopped supervisor still loses the VMM at the deadline: both kills come from the kernel.
|
||||
require.NoError(t, cmd.Process.Signal(syscall.SIGSTOP))
|
||||
exited, err := pidfdExited(pidfd, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exited, "VMM outlived its deadline")
|
||||
state, err := cmd.Process.Wait()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, syscall.SIGKILL, state.Sys().(syscall.WaitStatus).Signal())
|
||||
}
|
||||
|
||||
func TestFirecrackerDiesWithItsSupervisor(t *testing.T) {
|
||||
cmd, pidfd := supervised(t, boottime(t)+time.Hour)
|
||||
exited, err := pidfdExited(pidfd, 0)
|
||||
require.NoError(t, err)
|
||||
require.False(t, exited)
|
||||
|
||||
require.NoError(t, cmd.Process.Kill())
|
||||
_, _ = cmd.Process.Wait()
|
||||
// Only the kernel's parent-death signal can end the VMM here; nothing else signals it.
|
||||
exited, err = pidfdExited(pidfd, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exited, "VMM outlived its supervisor")
|
||||
}
|
||||
|
||||
// A supervisor that never started launched nothing, so the attempt is
|
||||
// released; one that started without handing over a pidfd may have started
|
||||
// Firecracker, so the attempt is held.
|
||||
func TestSettleFirecracker_OnlyANeverStartedSupervisorIsProvenStopped(t *testing.T) {
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cases := map[string]struct {
|
||||
ctx context.Context
|
||||
cmd func() *exec.Cmd
|
||||
want bool
|
||||
}{
|
||||
"cancelled before Start": {cancelled, func() *exec.Cmd { return exec.Command("/bin/true") }, true},
|
||||
"failed Start": {context.Background(), func() *exec.Cmd { return exec.Command(filepath.Join(t.TempDir(), "missing")) }, true},
|
||||
"started, no pidfd": {context.Background(), func() *exec.Cmd { return exec.Command("/bin/true") }, false},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cmd := tc.cmd()
|
||||
fc := &vmHandle{fd: -1}
|
||||
out, _ := attempt.Run(tc.ctx, cmd, 10*time.Second, func(context.Context, int) error {
|
||||
fc.callbackDone()
|
||||
return errors.New("supervisor exited before its ack")
|
||||
})
|
||||
require.True(t, out.Stopped, "the supervisor itself is proven stopped")
|
||||
<-out.SessionDone
|
||||
assert.Equal(t, tc.want, settleFirecracker(out.SessionDone, fc, cmd.Process != nil))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirecrackerAckRequiresTheSupervisorsChild(t *testing.T) {
|
||||
ack, child, err := ackPair()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = ack.Close() }()
|
||||
self, err := unix.PidfdOpen(os.Getpid(), 0)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, unix.Sendmsg(int(child.Fd()), []byte(fcAck), unix.UnixRights(self), nil, 0))
|
||||
_ = unix.Close(self)
|
||||
_ = child.Close()
|
||||
|
||||
_, _, err = awaitFirecracker(context.Background(), ack, 1, time.Second)
|
||||
require.ErrorIs(t, err, ErrNotArmed)
|
||||
assert.Contains(t, err.Error(), "not the supervisor's child")
|
||||
}
|
||||
|
||||
// The archive the daemon writes is read by the guest's own identity reader
|
||||
// once extracted, as the runtime image's init extracts it.
|
||||
func TestConfigArchiveIsReadByTheGuestReaders(t *testing.T) {
|
||||
gen, err := guestconn.NewGeneration("ap-southeast-2/000000000001/fn/env/1", time.Hour)
|
||||
require.NoError(t, err)
|
||||
entries := map[string][]byte{fwCfgNetcfg: []byte("NIC0_MAC=02:00:00:00:00:01\n")}
|
||||
for name, b := range gen.GuestEntries("192.0.2.1:4000") {
|
||||
entries[lambdav1.FwCfgPrefix+name] = b
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), configArchive)
|
||||
require.NoError(t, writeConfigArchive(path, entries))
|
||||
|
||||
root := filepath.Join(t.TempDir(), "cfg")
|
||||
extract(t, path, root)
|
||||
_, err = hostlink.LoadIdentity(filepath.Join(root, "by_name", lambdav1.FwCfgPrefix))
|
||||
require.NoError(t, err)
|
||||
netcfg, err := os.ReadFile(filepath.Join(root, "by_name", fwCfgNetcfg, "raw"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entries[fwCfgNetcfg], netcfg)
|
||||
}
|
||||
|
||||
func extract(t *testing.T, archive, root string) {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(archive)
|
||||
require.NoError(t, err)
|
||||
tr := tar.NewReader(bytes.NewReader(b))
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.False(t, strings.Contains(h.Name, ".."), h.Name)
|
||||
p := filepath.Join(root, h.Name)
|
||||
if h.Typeflag == tar.TypeDir {
|
||||
require.NoError(t, os.MkdirAll(p, 0o700))
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(tr)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(p, data, 0o600))
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
package testlink
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/function"
|
||||
)
|
||||
|
||||
// Readiness admits a function the test link can run, and only while d serves
|
||||
// the pinned image on the cluster's only node, so no gateway or daemon is
|
||||
// handed an Active function it cannot run.
|
||||
func (d *Daemon) Readiness(imagePin string, nodes int) function.Readiness {
|
||||
return func(_ context.Context, rec function.Record) error {
|
||||
if rec.EnvCipher != "" {
|
||||
return notReady("The function cannot run here: test-link Lambda does not support environment variables.")
|
||||
}
|
||||
if !validHandler(rec.Handler) {
|
||||
return notReady("The function cannot run here: test-link Lambda runs only a handler of 1 to 128 characters with no whitespace.")
|
||||
}
|
||||
if rec.Code.Size <= 0 || rec.Code.Size > MaxCodeBytes {
|
||||
return notReady("The function cannot run here: its code is larger than test-link Lambda accepts.")
|
||||
}
|
||||
if nodes != 1 {
|
||||
return notReady("The function cannot run here: test-link Lambda activates only on a single-node cluster.")
|
||||
}
|
||||
if !d.Serving() || d.Image == nil || d.Image.Digest != imagePin {
|
||||
return notReady(function.ReasonCreating)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// notReady is an unmet activation condition, worded as the StateReason.
|
||||
type notReady string
|
||||
|
||||
func (n notReady) Error() string { return string(n) }
|
||||
@@ -1,173 +0,0 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
//test:in-package — reuses the package fixtures writeImage, testImageDigest and controlName
|
||||
package testlink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/function"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
readyAccount = "000000000001"
|
||||
readyRegion = "ap-southeast-2"
|
||||
)
|
||||
|
||||
// storedFunction creates a function through the real owner and records its
|
||||
// code as stored, leaving it Pending the way CreateFunction does.
|
||||
func storedFunction(t *testing.T, svc *function.MetaService, name string) {
|
||||
t.Helper()
|
||||
storedFunctionWith(t, svc, name, "", 9)
|
||||
}
|
||||
|
||||
func storedFunctionWith(t *testing.T, svc *function.MetaService, name, envCipher string, size int64) {
|
||||
t.Helper()
|
||||
storedFunctionHandler(t, svc, name, "bootstrap", envCipher, size)
|
||||
}
|
||||
|
||||
func storedFunctionHandler(t *testing.T, svc *function.MetaService, name, handler, envCipher string, size int64) {
|
||||
t.Helper()
|
||||
created, err := svc.Create(t.Context(), &function.CreateRequest{Region: readyRegion, Record: function.Record{
|
||||
FunctionID: "id-" + name, FunctionName: name, Role: "arn:aws:iam::000000000001:role/r",
|
||||
Runtime: "provided.al2023", Handler: handler, Timeout: 3, MemorySize: 128, EnvCipher: envCipher,
|
||||
Code: function.Code{RevisionID: "rev-" + name},
|
||||
}}, readyAccount)
|
||||
require.NoError(t, err)
|
||||
_, err = svc.CodeStored(t.Context(), &function.CodeStoredRequest{Region: readyRegion, FunctionName: name,
|
||||
Revision: created.Revision, SHA256: testImageDigest, Size: size}, readyAccount)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func functionState(t *testing.T, svc *function.MetaService, name string) function.Record {
|
||||
t.Helper()
|
||||
got, err := svc.Get(t.Context(), &function.GetRequest{Region: readyRegion, FunctionName: name}, readyAccount)
|
||||
require.NoError(t, err)
|
||||
require.True(t, got.Found)
|
||||
return got.Record
|
||||
}
|
||||
|
||||
func readyOwner(t *testing.T) *function.MetaService {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
return function.NewMetaService(testutil.NewJetStream(t, nc))
|
||||
}
|
||||
|
||||
func TestReadiness_ActivatesOnlyOnceTheDaemonServesThePinnedImage(t *testing.T) {
|
||||
svc := readyOwner(t)
|
||||
im, err := PrepareImage(writeImage(t), testImageDigest)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(im.Close)
|
||||
d := &Daemon{Node: "node1", Image: im}
|
||||
svc.SetReadiness(d.Readiness(testImageDigest, 1))
|
||||
storedFunction(t, svc, "hello")
|
||||
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
waiting := functionState(t, svc, "hello")
|
||||
assert.Equal(t, function.StatePending, waiting.State, "a prepared image that is not served activates nothing")
|
||||
assert.Equal(t, function.ReasonCreating, waiting.StateReason)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
require.NoError(t, d.ServeControl(ctx, controlName(t), os.Getuid()))
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
active := functionState(t, svc, "hello")
|
||||
assert.Equal(t, function.StateActive, active.State)
|
||||
assert.Empty(t, active.StateReason)
|
||||
|
||||
cancel()
|
||||
require.Eventually(t, func() bool { return !d.Serving() }, 5*time.Second, 10*time.Millisecond,
|
||||
"a stopped control socket is reported as not serving")
|
||||
storedFunction(t, svc, "later")
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
assert.Equal(t, function.StatePending, functionState(t, svc, "later").State)
|
||||
}
|
||||
|
||||
func TestReadiness_RefusesAnotherPinOrAnotherNode(t *testing.T) {
|
||||
im, err := PrepareImage(writeImage(t), testImageDigest)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(im.Close)
|
||||
d := &Daemon{Node: "node1", Image: im}
|
||||
require.NoError(t, d.ServeControl(t.Context(), controlName(t), os.Getuid()))
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
pin string
|
||||
nodes int
|
||||
reason string
|
||||
}{
|
||||
{"other pin", "0000000000000000000000000000000000000000000000000000000000000000", 1, function.ReasonCreating},
|
||||
{"two nodes", testImageDigest, 2, "The function cannot run here: test-link Lambda activates only on a single-node cluster."},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := readyOwner(t)
|
||||
svc.SetReadiness(d.Readiness(tc.pin, tc.nodes))
|
||||
storedFunction(t, svc, "hello")
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
got := functionState(t, svc, "hello")
|
||||
assert.Equal(t, function.StatePending, got.State)
|
||||
assert.Equal(t, tc.reason, got.StateReason)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadiness_RefusesFunctionsTheTestLinkCannotRun(t *testing.T) {
|
||||
im, err := PrepareImage(writeImage(t), testImageDigest)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(im.Close)
|
||||
d := &Daemon{Node: "node1", Image: im}
|
||||
require.NoError(t, d.ServeControl(t.Context(), controlName(t), os.Getuid()))
|
||||
svc := readyOwner(t)
|
||||
svc.SetReadiness(d.Readiness(testImageDigest, 1))
|
||||
storedFunctionWith(t, svc, "env", "sealed", 9)
|
||||
storedFunctionWith(t, svc, "huge", "", MaxCodeBytes+1)
|
||||
storedFunctionWith(t, svc, "edge", "", MaxCodeBytes)
|
||||
storedFunctionHandler(t, svc, "spaced", "boot\u00a0strap", "", 9)
|
||||
storedFunctionHandler(t, svc, "longhandler", strings.Repeat("h", 129), "", 9)
|
||||
storedFunctionHandler(t, svc, "edgehandler", strings.Repeat("h", 128), "", 9)
|
||||
|
||||
require.NoError(t, svc.ActivatePending(t.Context()))
|
||||
assert.Equal(t, "The function cannot run here: test-link Lambda does not support environment variables.", functionState(t, svc, "env").StateReason)
|
||||
assert.Equal(t, function.StatePending, functionState(t, svc, "env").State)
|
||||
assert.Equal(t, "The function cannot run here: its code is larger than test-link Lambda accepts.", functionState(t, svc, "huge").StateReason)
|
||||
assert.Equal(t, function.StatePending, functionState(t, svc, "huge").State)
|
||||
assert.Equal(t, function.StateActive, functionState(t, svc, "edge").State, "the largest accepted code copy activates")
|
||||
for _, name := range []string{"spaced", "longhandler"} {
|
||||
assert.Equal(t, function.StatePending, functionState(t, svc, name).State, name)
|
||||
assert.Equal(t, "The function cannot run here: test-link Lambda runs only a handler of 1 to 128 characters with no whitespace.", functionState(t, svc, name).StateReason, name)
|
||||
}
|
||||
assert.Equal(t, function.StateActive, functionState(t, svc, "edgehandler").State, "the longest accepted handler activates")
|
||||
}
|
||||
|
||||
func TestReadiness_ServingEndsWhenAcceptingStops(t *testing.T) {
|
||||
d := &Daemon{Node: "node1"}
|
||||
name := controlName(t)
|
||||
ln, err := ListenControl(name)
|
||||
require.NoError(t, err)
|
||||
inHandle, release := make(chan struct{}), make(chan struct{})
|
||||
srv := d.controlServer(os.Getuid())
|
||||
srv.Handle = func(context.Context, []byte, Credential) Reply {
|
||||
close(inHandle)
|
||||
<-release
|
||||
return Reply{Outcome: OutcomeDone}
|
||||
}
|
||||
d.serve(t.Context(), ln, srv)
|
||||
require.True(t, d.Serving())
|
||||
go func() { _, _ = (&ControlClient{Name: name, DaemonUID: os.Getuid(), RequestLimit: MaxFrameBytes, ReplyLimit: MaxFrameBytes, IOTimeout: 5 * time.Second}).Exchange(context.Background(), []byte("{}"), testCred, time.Now().Add(5*time.Second)) }()
|
||||
select {
|
||||
case <-inHandle:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the request never reached Handle")
|
||||
}
|
||||
defer close(release)
|
||||
|
||||
require.NoError(t, ln.Close(), "the listener fails under an outstanding invocation")
|
||||
assert.Eventually(t, func() bool { return !d.Serving() }, 2*time.Second, 10*time.Millisecond,
|
||||
"serving ends while the invocation is still held, not after the drain")
|
||||
}
|
||||
@@ -2333,7 +2333,6 @@ func (d *Daemon) startCluster() error {
|
||||
}
|
||||
|
||||
d.elbv2Service.StartLifecycleReaper(context.Background())
|
||||
d.elbv2Service.StartDataPathReconciler(d.ctx, d.node)
|
||||
|
||||
d.eksService, err = initServiceWithRetry("EKS service", func() (*ekscluster.EKSServiceImpl, error) {
|
||||
return ekscluster.NewEKSServiceImpl(d.buildEKSServiceDeps())
|
||||
|
||||
@@ -3,18 +3,14 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/execution"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/network/host"
|
||||
@@ -31,28 +27,6 @@ var lambdaHardKillSHA256 string
|
||||
// image bytes and does not authenticate where they came from.
|
||||
var lambdaImageDigest string
|
||||
|
||||
// lambdaFirecrackerSHA256 pins the Firecracker v1.17.0 x86_64 release binary.
|
||||
// A node whose test-link image directory holds a firecracker binary boots
|
||||
// every attempt under it, and does not serve unless the binary matches.
|
||||
const lambdaFirecrackerSHA256 = "99ad0f5cd0514a88aad0e9ae8cfdb3cc3b4ab9d190e1194602406c786b5de7a5"
|
||||
|
||||
// checkFirecracker refuses a Firecracker binary other than the pinned release.
|
||||
func checkFirecracker(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("firecracker binary: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return fmt.Errorf("firecracker binary: %w", err)
|
||||
}
|
||||
if got := hex.EncodeToString(h.Sum(nil)); got != lambdaFirecrackerSHA256 {
|
||||
return errors.New("firecracker binary does not match the pinned release")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startLambdaTestLink serves controlled-test Lambda invocations for this node.
|
||||
// Recovery needs only the owner store, taps and addresses, so it starts even
|
||||
// when the bridge address, initializer, pin or download trust would not serve.
|
||||
@@ -96,20 +70,9 @@ func (d *Daemon) startLambdaTestLink() {
|
||||
InitializerSHA256: lambdaHardKillSHA256,
|
||||
HTTP: httpClient,
|
||||
}
|
||||
fc := filepath.Join(imageDir, "firecracker")
|
||||
if _, err := os.Lstat(fc); err == nil {
|
||||
tl.Firecracker = fc
|
||||
}
|
||||
err = runLambdaTestLink(
|
||||
func() { go tl.RecoverLoop(d.ctx, testlink.RecoverFirst, testlink.RecoverMax) },
|
||||
func() error {
|
||||
if tl.Firecracker != "" {
|
||||
if err := checkFirecracker(tl.Firecracker); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return lambdaTestLinkReady(tl, imageDir, lambdaImageDigest, trustErr)
|
||||
},
|
||||
func() error { return lambdaTestLinkReady(tl, imageDir, lambdaImageDigest, trustErr) },
|
||||
func() error {
|
||||
gatewayUID, err := testlink.LookupUID(testlink.GatewayUser)
|
||||
if err != nil {
|
||||
@@ -125,17 +88,8 @@ func (d *Daemon) startLambdaTestLink() {
|
||||
}
|
||||
slog.Warn("Lambda test link serving: test-only, not for production; the guest does not verify owner grant authority",
|
||||
"node", d.node, "image_digest", tl.Image.Digest)
|
||||
if d.lambdaMetaService != nil && d.clusterConfig != nil {
|
||||
d.lambdaMetaService.SetReadiness(tl.Readiness(lambdaImageDigest, len(d.clusterConfig.Nodes)))
|
||||
go d.lambdaMetaService.RunActivation(d.ctx, lambdaActivationInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// lambdaActivationInterval bounds how long a function whose activation was
|
||||
// missed, by a crash or an earlier refusal, waits for the next pass.
|
||||
const lambdaActivationInterval = 30 * time.Second
|
||||
|
||||
|
||||
// lambdaTestLinkReady runs the serving checks and, only once they pass,
|
||||
// prepares the pinned image into tl; a refusal leaves tl.Image nil.
|
||||
func lambdaTestLinkReady(tl *testlink.Daemon, imageDir, imagePin string, trustErr error) error {
|
||||
|
||||
@@ -154,17 +154,8 @@ func (a *MgmtIPAllocator) BindKV(jsManager *JetStreamManager, node string) {
|
||||
// an address is free cluster-wide on its own — and is refused otherwise.
|
||||
func (a *MgmtIPAllocator) Allocate(instanceID string) (string, error) {
|
||||
a.mu.Lock()
|
||||
// Addresses this node's own instances hold stay off the table even when the
|
||||
// cluster record has lost their entries.
|
||||
held := make([]MgmtIPEntry, 0, len(a.allocated))
|
||||
for id, ip := range a.allocated {
|
||||
held = append(held, MgmtIPEntry{IP: ip, InstanceID: id})
|
||||
}
|
||||
if ip, ok := a.allocated[instanceID]; ok {
|
||||
a.mu.Unlock()
|
||||
if other := heldByOther(held, ip, instanceID); other != "" {
|
||||
return "", fmt.Errorf("mgmt IP %s for %s is also held locally by %s", ip, instanceID, other)
|
||||
}
|
||||
return ip, nil
|
||||
}
|
||||
jsManager, node, subnet := a.jsManager, a.node, a.subnet
|
||||
@@ -180,7 +171,29 @@ func (a *MgmtIPAllocator) Allocate(instanceID string) (string, error) {
|
||||
// CAS write that follows is a harmless no-op against the same revision.
|
||||
var allocatedIP string
|
||||
var mutateErr error
|
||||
_, err := updateMgmtIPAMWithRetry(jsManager, subnet, a.allocMutate(instanceID, node, subnet, held, &allocatedIP, &mutateErr), true)
|
||||
_, err := updateMgmtIPAMWithRetry(jsManager, subnet, func(r *MgmtIPRecord) {
|
||||
if r.Subnet == "" {
|
||||
r.Subnet = subnet
|
||||
}
|
||||
|
||||
// Idempotent: an instance that already holds an address (allocated
|
||||
// by this node earlier, or by another node before a restart lost
|
||||
// the local cache) keeps it rather than taking a second one.
|
||||
for _, e := range r.Allocated {
|
||||
if e.InstanceID == instanceID {
|
||||
allocatedIP = e.IP
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ip, findErr := nextFreeIP(a.baseIP, a.rangeMin, a.rangeMax, r.Allocated)
|
||||
if findErr != nil {
|
||||
mutateErr = findErr
|
||||
return
|
||||
}
|
||||
r.Allocated = append(r.Allocated, MgmtIPEntry{IP: ip, InstanceID: instanceID, Node: node})
|
||||
allocatedIP = ip
|
||||
}, true)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mgmt IP CAS update for subnet %s: %w", subnet, err)
|
||||
}
|
||||
@@ -196,16 +209,6 @@ func (a *MgmtIPAllocator) Allocate(instanceID string) (string, error) {
|
||||
return allocatedIP, nil
|
||||
}
|
||||
|
||||
// heldByOther names a cached instance other than id holding ip, or "".
|
||||
func heldByOther(held []MgmtIPEntry, ip, id string) string {
|
||||
for _, e := range held {
|
||||
if e.IP == ip && e.InstanceID != id {
|
||||
return e.InstanceID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// nextFreeIP scans rangeMin-rangeMax on the /24 rooted at baseIP, skipping
|
||||
// every IP already present in allocated regardless of which entry's Node it
|
||||
// belongs to — the range is shared
|
||||
@@ -508,37 +511,3 @@ func (d *Daemon) primeMgmtNeighEntry(instanceID, mgmtIP, mgmtMAC string) {
|
||||
"instanceId", instanceID, "mgmtIP", mgmtIP, "mgmtMAC", mgmtMAC, "bridge", bridge, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// allocMutate returns the CAS callback for Allocate. Results go out through
|
||||
// ipOut and errOut because casUpdate's mutate has no return value.
|
||||
func (a *MgmtIPAllocator) allocMutate(instanceID, node, subnet string, held []MgmtIPEntry, ipOut *string, errOut *error) func(*MgmtIPRecord) {
|
||||
return func(r *MgmtIPRecord) {
|
||||
// Each CAS attempt starts clean, so a retry never returns an earlier attempt's result.
|
||||
*ipOut, *errOut = "", nil
|
||||
if r.Subnet == "" {
|
||||
r.Subnet = subnet
|
||||
}
|
||||
|
||||
// Idempotent: an instance that already holds an address (allocated
|
||||
// by this node earlier, or by another node before a restart lost
|
||||
// the local cache) keeps it rather than taking a second one.
|
||||
for _, e := range r.Allocated {
|
||||
if e.InstanceID == instanceID {
|
||||
if other := heldByOther(held, e.IP, instanceID); other != "" {
|
||||
*errOut = fmt.Errorf("mgmt IP %s recorded for %s is held locally by %s", e.IP, instanceID, other)
|
||||
return
|
||||
}
|
||||
*ipOut = e.IP
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ip, findErr := nextFreeIP(a.baseIP, a.rangeMin, a.rangeMax, append(held, r.Allocated...))
|
||||
if findErr != nil {
|
||||
*errOut = findErr
|
||||
return
|
||||
}
|
||||
r.Allocated = append(r.Allocated, MgmtIPEntry{IP: ip, InstanceID: instanceID, Node: node})
|
||||
*ipOut = ip
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,76 +472,6 @@ func TestMgmtIPAllocator_Allocate_IdempotentAcrossNodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An instance whose cluster-record entry has gone (the record lost it while the
|
||||
// instance kept running) still holds its address on this node, so a new
|
||||
// allocation must not be handed that address.
|
||||
func TestMgmtIPAllocator_Allocate_SkipsLocallyHeldWhenRecordLost(t *testing.T) {
|
||||
t.Parallel()
|
||||
jsm := newTestMgmtJSM(t)
|
||||
|
||||
a, err := NewMgmtIPAllocator("10.41.8.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.BindKV(jsm, "node-1")
|
||||
cleanupMgmtIPAM(t, jsm, a)
|
||||
|
||||
held, err := a.Allocate("i-system")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := jsm.clusterKV.Delete(context.Background(), mgmtIPAMKeyPrefix+a.subnet); err != nil {
|
||||
t.Fatalf("drop record: %v", err)
|
||||
}
|
||||
|
||||
got, err := a.Allocate("lambda-attempt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == held {
|
||||
t.Fatalf("new allocation reused %s, still held by i-system", got)
|
||||
}
|
||||
if again, err := a.Allocate("i-system"); err != nil || again != held {
|
||||
t.Errorf("holder re-allocate = %q, %v; want %q", again, err, held)
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate refuses a recorded entry whose address another local instance holds.
|
||||
func TestMgmtIPAllocator_Allocate_RefusesRecordedConflictWithLocalHolder(t *testing.T) {
|
||||
t.Parallel()
|
||||
jsm := newTestMgmtJSM(t)
|
||||
|
||||
a, err := NewMgmtIPAllocator("10.42.8.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.BindKV(jsm, "node-1")
|
||||
cleanupMgmtIPAM(t, jsm, a)
|
||||
|
||||
held, err := a.Allocate("i-system")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, err := NewMgmtIPAllocator("10.42.8.2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other.BindKV(jsm, "node-2")
|
||||
if err := jsm.clusterKV.Delete(context.Background(), mgmtIPAMKeyPrefix+a.subnet); err != nil {
|
||||
t.Fatalf("drop record: %v", err)
|
||||
}
|
||||
// The record now names the held address for a different instance.
|
||||
other.Claim("lambda-attempt", held)
|
||||
|
||||
if got, err := a.Allocate("lambda-attempt"); err == nil {
|
||||
t.Fatalf("Allocate returned %s, which i-system holds locally", got)
|
||||
}
|
||||
a.Claim("i-dup", held)
|
||||
if got, err := a.Allocate("i-dup"); err == nil {
|
||||
t.Fatalf("cached duplicate returned %s, which i-system holds locally", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMgmtIPAllocator_Release_FreesAddressClusterWide proves Release's
|
||||
// effect is visible cluster-wide: a second allocator, not the one that
|
||||
// released the address, can immediately take it.
|
||||
@@ -938,9 +868,8 @@ func TestMgmtIPAllocator_ClaimLetsTheNewHolderReleaseItLater(t *testing.T) {
|
||||
t.Errorf("the holder could not release its own address: entry still %+v", entry)
|
||||
}
|
||||
|
||||
// And the address is genuinely back, not merely unrecorded. node-1's cache
|
||||
// still names its dropped copy, so the holder that released it allocates.
|
||||
reused, err := node2.Allocate("i-next")
|
||||
// And the address is genuinely back, not merely unrecorded.
|
||||
reused, err := node1.Allocate("i-next")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1181,34 +1110,3 @@ func TestMgmtIPAllocator_ReleaseDropsLocalOnForeignHolderAndKVFailure(t *testing
|
||||
t.Errorf("local entries after release with KV down = %d, want 0", node3.AllocatedCount())
|
||||
}
|
||||
}
|
||||
|
||||
// A CAS retry that sees the conflicting entry gone must report its own
|
||||
// result, not the refusal left by the earlier attempt.
|
||||
func TestMgmtIPAllocator_AllocMutate_RetryClearsEarlierConflict(t *testing.T) {
|
||||
a, err := NewMgmtIPAllocator("10.30.8.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held := []MgmtIPEntry{{IP: "10.30.8.10", InstanceID: "i-sqs"}}
|
||||
var ip string
|
||||
var mutateErr error
|
||||
mutate := a.allocMutate("i-fn", "node1", "10.30.8.0/24", held, &ip, &mutateErr)
|
||||
|
||||
mutate(&MgmtIPRecord{Allocated: []MgmtIPEntry{{IP: "10.30.8.10", InstanceID: "i-fn", Node: "node1"}}})
|
||||
if mutateErr == nil || ip != "" {
|
||||
t.Fatalf("conflicted attempt: ip=%q err=%v, want refusal", ip, mutateErr)
|
||||
}
|
||||
|
||||
clean := &MgmtIPRecord{}
|
||||
mutate(clean)
|
||||
if mutateErr != nil {
|
||||
t.Fatalf("retry kept the earlier refusal: %v", mutateErr)
|
||||
}
|
||||
if ip == "" || ip == "10.30.8.10" {
|
||||
t.Fatalf("retry allocated %q, want a free address other than the held one", ip)
|
||||
}
|
||||
want := MgmtIPEntry{IP: ip, InstanceID: "i-fn", Node: "node1"}
|
||||
if len(clean.Allocated) != 1 || clean.Allocated[0] != want {
|
||||
t.Fatalf("record = %+v, want [%+v]", clean.Allocated, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,14 +97,6 @@ func (d *Daemon) serveSystemLaunch(msg *nats.Msg, launch systemLaunchFunc) strin
|
||||
"instanceId", output.InstanceID, "err", subErr)
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
rebootErr := d.subscribeSystemRebootLocked(output.InstanceID)
|
||||
d.mu.Unlock()
|
||||
if rebootErr != nil {
|
||||
slog.Error("system.LaunchInstance: failed to subscribe reboot subject",
|
||||
"instanceId", output.InstanceID, "err", rebootErr)
|
||||
}
|
||||
|
||||
respondWithSystemLaunchOutput(msg, output)
|
||||
return outcomeSuccess
|
||||
}
|
||||
@@ -183,121 +175,21 @@ func (d *Daemon) terminateSystemInstanceRemote(instanceID string) error {
|
||||
return fmt.Errorf("decode routed terminate reply %s: %w", instanceID, err)
|
||||
}
|
||||
if env.Error != "" {
|
||||
return routedSystemError("terminate", instanceID, env.Error)
|
||||
if strings.Contains(env.Error, systeminstance.ErrSystemInstanceNotFound.Error()) {
|
||||
return fmt.Errorf("%w: %s", systeminstance.ErrSystemInstanceNotFound, instanceID)
|
||||
}
|
||||
if strings.Contains(env.Error, systeminstance.ErrSystemInstanceNotSystem.Error()) {
|
||||
return fmt.Errorf("%w: %s", systeminstance.ErrSystemInstanceNotSystem, instanceID)
|
||||
}
|
||||
if strings.Contains(env.Error, systeminstance.ErrSystemInstanceOwnershipUnknown.Error()) {
|
||||
return fmt.Errorf("%w: %s", systeminstance.ErrSystemInstanceOwnershipUnknown, instanceID)
|
||||
}
|
||||
return fmt.Errorf("routed terminate %s: %s", instanceID, env.Error)
|
||||
}
|
||||
slog.Info("terminateSystemInstanceRemote: owner terminated VM", "instanceId", instanceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// routedSystemError maps an owner's error text back to the sentinel it carried,
|
||||
// so callers keep telling gone, customer-owned and unreadable apart.
|
||||
func routedSystemError(op, instanceID, text string) error {
|
||||
for _, sentinel := range []error{
|
||||
systeminstance.ErrSystemInstanceNotFound,
|
||||
systeminstance.ErrSystemInstanceNotSystem,
|
||||
systeminstance.ErrSystemInstanceOwnershipUnknown,
|
||||
} {
|
||||
if strings.Contains(text, sentinel.Error()) {
|
||||
return fmt.Errorf("%w: %s", sentinel, instanceID)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("routed %s %s: %s", op, instanceID, text)
|
||||
}
|
||||
|
||||
// rebootSystemInstanceRemote routes a reboot to the owning daemon over
|
||||
// system.RebootInstance.{id}. No responder means the owner is unreachable and
|
||||
// is reported as ErrSystemInstanceNotFound; the caller retries and must not
|
||||
// count it as a repair.
|
||||
func (d *Daemon) rebootSystemInstanceRemote(instanceID string) error {
|
||||
if d.natsConn == nil {
|
||||
return fmt.Errorf("%w: %s", systeminstance.ErrSystemInstanceNotFound, instanceID)
|
||||
}
|
||||
subject := systemRebootSubject(instanceID)
|
||||
reply, err := d.natsConn.Request(subject, nil, systemRebootRemoteTimeout)
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrNoResponders) {
|
||||
return fmt.Errorf("%w: %s", systeminstance.ErrSystemInstanceNotFound, instanceID)
|
||||
}
|
||||
return fmt.Errorf("route reboot %s: %w", instanceID, err)
|
||||
}
|
||||
var env systemInstanceTerminateEnvelope
|
||||
if err := json.Unmarshal(reply.Data, &env); err != nil {
|
||||
return fmt.Errorf("decode routed reboot reply %s: %w", instanceID, err)
|
||||
}
|
||||
if env.Error != "" {
|
||||
return routedSystemError("reboot", instanceID, env.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// systemRebootRemoteTimeout caps a routed reboot round trip. The owner admits
|
||||
// the reboot and returns; the guest restart runs in the background.
|
||||
const systemRebootRemoteTimeout = 15 * time.Second
|
||||
|
||||
func systemRebootSubject(instanceID string) string {
|
||||
return "system.RebootInstance." + instanceID
|
||||
}
|
||||
|
||||
// subscribeSystemRebootLocked registers the owning-node subscription for
|
||||
// system.RebootInstance.{instanceID}. Idempotent; the caller holds d.mu.
|
||||
func (d *Daemon) subscribeSystemRebootLocked(instanceID string) error {
|
||||
if d.natsConn == nil {
|
||||
return nil
|
||||
}
|
||||
subject := systemRebootSubject(instanceID)
|
||||
if _, exists := d.natsSubscriptions[subject]; exists {
|
||||
return nil
|
||||
}
|
||||
sub, err := d.natsConn.Subscribe(subject, d.handleSystemRebootInstance)
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscribe %s: %w", subject, err)
|
||||
}
|
||||
d.natsSubscriptions[subject] = sub
|
||||
return nil
|
||||
}
|
||||
|
||||
// subscribeSystemReboot is subscribeSystemRebootLocked for callers without d.mu.
|
||||
func (d *Daemon) subscribeSystemReboot(instanceID string) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.subscribeSystemRebootLocked(instanceID)
|
||||
}
|
||||
|
||||
// handleSystemRebootInstance is the NATS subscriber for
|
||||
// system.RebootInstance.{instanceID}. Only the owning daemon subscribes.
|
||||
func (d *Daemon) handleSystemRebootInstance(msg *nats.Msg) {
|
||||
d.systemDispatchWg.Go(func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("system.RebootInstance: handler panic", "subject", msg.Subject, "panic", r)
|
||||
respondWithSystemRebootReply(msg, awserrors.ErrorServerInternal)
|
||||
}
|
||||
}()
|
||||
id, ok := strings.CutPrefix(msg.Subject, "system.RebootInstance.")
|
||||
if !ok || id == "" {
|
||||
respondWithSystemRebootReply(msg, awserrors.ErrorInvalidInstanceIDNotFound)
|
||||
return
|
||||
}
|
||||
// The local body, not the routing wrapper: only the owner subscribes.
|
||||
if err := d.rebootSystemInstanceLocal(id); err != nil {
|
||||
respondWithSystemRebootReply(msg, err.Error())
|
||||
return
|
||||
}
|
||||
respondWithSystemRebootReply(msg, "")
|
||||
})
|
||||
}
|
||||
|
||||
func respondWithSystemRebootReply(msg *nats.Msg, errMsg string) {
|
||||
payload, err := json.Marshal(systemInstanceTerminateEnvelope{Error: errMsg})
|
||||
if err != nil {
|
||||
slog.Error("system.RebootInstance: marshal reply failed", "err", err)
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(payload); err != nil {
|
||||
slog.Error("system.RebootInstance: respond failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// subscribeSystemTerminate registers an owning-node subscription for
|
||||
// system.TerminateInstance.{instanceID}. Idempotent — calling for an
|
||||
// already-bound subscription is a no-op.
|
||||
@@ -374,10 +266,6 @@ func (d *Daemon) serveSystemTerminateInstance(msg *nats.Msg) {
|
||||
}
|
||||
delete(d.natsSubscriptions, subject)
|
||||
}
|
||||
if sub, exists := d.natsSubscriptions[systemRebootSubject(parts)]; exists {
|
||||
_ = sub.Unsubscribe()
|
||||
delete(d.natsSubscriptions, systemRebootSubject(parts))
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
respondWithSystemTerminateOK(msg)
|
||||
|
||||
@@ -682,19 +682,13 @@ func (d *Daemon) reclaimSystemInstanceEIP(instanceID string, cls systeminstance.
|
||||
// customer ID is refused as not a system instance, an unknown ID answered as
|
||||
// gone and an unreadable record as ownership unknown; nothing is touched.
|
||||
func (d *Daemon) requireSystemInstance(instanceID string) (systeminstance.Classification, error) {
|
||||
return d.requireSystemInstanceFor("TerminateSystemInstance", instanceID)
|
||||
}
|
||||
|
||||
// requireSystemInstanceFor is requireSystemInstance with the calling operation
|
||||
// named in the refusal log.
|
||||
func (d *Daemon) requireSystemInstanceFor(op, instanceID string) (systeminstance.Classification, error) {
|
||||
cls, err := d.classifySystemInstance(instanceID)
|
||||
if err != nil {
|
||||
slog.Warn(op+": refused, ownership could not be read", "instanceId", instanceID, "err", err)
|
||||
slog.Warn("TerminateSystemInstance: refused, ownership could not be read", "instanceId", instanceID, "err", err)
|
||||
return cls, fmt.Errorf("%w: %s: %w", systeminstance.ErrSystemInstanceOwnershipUnknown, instanceID, err)
|
||||
}
|
||||
if cls.Ownership != systeminstance.OwnershipSystem {
|
||||
slog.Warn(op+": refused, not a system instance", "instanceId", instanceID, "ownership", cls.Ownership)
|
||||
slog.Warn("TerminateSystemInstance: refused, not a system instance", "instanceId", instanceID, "ownership", cls.Ownership)
|
||||
if cls.Ownership == systeminstance.OwnershipCustomer {
|
||||
return cls, fmt.Errorf("%w: %s", systeminstance.ErrSystemInstanceNotSystem, instanceID)
|
||||
}
|
||||
@@ -744,43 +738,6 @@ func (d *Daemon) terminateSystemInstanceLocal(instanceID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RebootSystemInstance restarts a load balancer VM into a fresh QEMU, keeping
|
||||
// its instance ID, ENIs and addresses. When this node does not run the VM the
|
||||
// request is routed to the owning daemon; no responder means the owner is
|
||||
// unreachable, reported as ErrSystemInstanceNotFound for the caller to retry.
|
||||
func (d *Daemon) RebootSystemInstance(instanceID string) error {
|
||||
if _, err := d.requireSystemInstanceFor("RebootSystemInstance", instanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := d.vmMgr.Get(instanceID); exists {
|
||||
return d.rebootSystemInstanceLocal(instanceID)
|
||||
}
|
||||
return d.rebootSystemInstanceRemote(instanceID)
|
||||
}
|
||||
|
||||
// rebootSystemInstanceLocal restarts a VM owned by this node. It refuses unless
|
||||
// the VM is a load balancer in the system account, and never terminates.
|
||||
func (d *Daemon) rebootSystemInstanceLocal(instanceID string) error {
|
||||
if _, err := d.requireSystemInstanceFor("RebootSystemInstance", instanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
instance, exists := d.vmMgr.Get(instanceID)
|
||||
if !exists {
|
||||
return fmt.Errorf("%w: %s", systeminstance.ErrSystemInstanceNotFound, instanceID)
|
||||
}
|
||||
if instance.ManagedBy != tags.ManagedByELBv2 || instance.AccountID != awsidentifiers.GlobalAccountID {
|
||||
slog.Warn("RebootSystemInstance: refused, not a load balancer system VM",
|
||||
"instanceId", instanceID, "managedBy", instance.ManagedBy)
|
||||
return fmt.Errorf("%w: %s", systeminstance.ErrSystemInstanceNotSystem, instanceID)
|
||||
}
|
||||
if err := d.vmMgr.Reboot(context.Background(), instanceID); err != nil {
|
||||
slog.Error("RebootSystemInstance: vmMgr.Reboot failed", "instanceId", instanceID, "err", err)
|
||||
return fmt.Errorf("reboot: %w", err)
|
||||
}
|
||||
slog.Info("RebootSystemInstance admitted", "instanceId", instanceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareInstanceRelaunch makes this node's world match an instance record it is
|
||||
// about to launch from.
|
||||
//
|
||||
@@ -1136,5 +1093,3 @@ func buildNetcfgBlob(nics []elbv2domain.NICConfig) (string, error) {
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
var _ systeminstance.SystemInstanceLauncher = (*Daemon)(nil)
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/systeminstance"
|
||||
elbv2domain "github.com/mulgadc/spinifex/spinifex/domains/elbv2"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/qmp"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// withMonitor gives a test VM a monitor client. A reboot ends the old process
|
||||
// and marks that client dead, which makes "Reboot ran" observable.
|
||||
func withMonitor(v *vm.VM) *qmp.QMPClient {
|
||||
v.QMPClient = &qmp.QMPClient{}
|
||||
return v.QMPClient
|
||||
}
|
||||
|
||||
func monitorDead(q *qmp.QMPClient) bool {
|
||||
q.Mu.Lock()
|
||||
defer q.Mu.Unlock()
|
||||
return q.Dead
|
||||
}
|
||||
|
||||
// Verifies: ADR-0003:S7.
|
||||
func TestS7_LBRepairRefusesACustomerInstance(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
inst *vm.VM
|
||||
loc recordLocation
|
||||
}{
|
||||
{"running customer", &vm.VM{ID: "i-r-running", Status: vm.StateRunning, AccountID: testAccountID}, atLive},
|
||||
{"stopped customer", &vm.VM{ID: "i-r-stopped", Status: vm.StateStopped, AccountID: testAccountID}, atStopped},
|
||||
{"load balancer manager in a customer account", &vm.VM{ID: "i-r-lbcust", Status: vm.StateRunning, AccountID: testAccountID, ManagedBy: tags.ManagedByELBv2}, atLive},
|
||||
{"customer account holding an EKS tag", &vm.VM{ID: "i-r-eks-cust", Status: vm.StateRunning, AccountID: testAccountID, ManagedBy: tags.ManagedByEKS}, atLive},
|
||||
{"system VM that is not a load balancer", &vm.VM{ID: "i-r-eks", Status: vm.StateRunning, AccountID: awsidentifiers.GlobalAccountID, ManagedBy: tags.ManagedByEKS}, atLive},
|
||||
}
|
||||
for _, route := range []string{"local", "wrapper", "routed", "nats launcher"} {
|
||||
t.Run(route, func(t *testing.T) {
|
||||
d := guardDaemon(t)
|
||||
terminated := countTerminations(d)
|
||||
launcher := elbv2domain.NewNATSSystemInstanceLauncher(d.natsConn, 5*time.Second)
|
||||
|
||||
for _, c := range cases {
|
||||
mon := withMonitor(c.inst)
|
||||
stage(t, d, c.inst, c.loc)
|
||||
var err error
|
||||
switch route {
|
||||
case "local":
|
||||
err = d.rebootSystemInstanceLocal(c.inst.ID)
|
||||
case "wrapper":
|
||||
err = d.RebootSystemInstance(c.inst.ID)
|
||||
case "routed":
|
||||
require.NoError(t, d.subscribeSystemReboot(c.inst.ID))
|
||||
err = d.rebootSystemInstanceRemote(c.inst.ID)
|
||||
case "nats launcher":
|
||||
require.NoError(t, d.subscribeSystemReboot(c.inst.ID))
|
||||
err = launcher.RebootSystemInstance(c.inst.ID)
|
||||
}
|
||||
require.Error(t, err, c.name)
|
||||
assert.Contains(t, err.Error(), systeminstance.ErrSystemInstanceNotSystem.Error(), c.name)
|
||||
assert.NotContains(t, err.Error(), systeminstance.ErrSystemInstanceNotFound.Error(), c.name)
|
||||
d.vmMgr.WaitForBackgroundWork()
|
||||
assert.False(t, monitorDead(mon), "%s: Reboot must never be reached", c.name)
|
||||
}
|
||||
assert.Zero(t, terminated.Load())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Verifies: ADR-0003:S7.
|
||||
func TestS7_LBRepairRebootsAndNeverTerminates(t *testing.T) {
|
||||
for _, route := range []string{"wrapper", "routed", "nats launcher"} {
|
||||
t.Run(route, func(t *testing.T) {
|
||||
d := guardDaemon(t)
|
||||
var mu sync.Mutex
|
||||
var moves []vm.InstanceState
|
||||
d.vmMgr.SetDeps(vm.Deps{NodeID: d.node, TransitionState: func(_ *vm.VM, to vm.InstanceState) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
moves = append(moves, to)
|
||||
return nil
|
||||
}})
|
||||
eip := &sysInstReclaimingEIP{}
|
||||
d.eipService = eip
|
||||
lb := &vm.VM{ID: "i-lb-" + strings.ReplaceAll(route, " ", "-"), Status: vm.StateRunning, AccountID: awsidentifiers.GlobalAccountID, ManagedBy: tags.ManagedByELBv2}
|
||||
mon := withMonitor(lb)
|
||||
stage(t, d, lb, atLive)
|
||||
|
||||
var err error
|
||||
switch route {
|
||||
case "wrapper":
|
||||
err = d.RebootSystemInstance(lb.ID)
|
||||
case "routed":
|
||||
require.NoError(t, d.subscribeSystemReboot(lb.ID))
|
||||
err = d.rebootSystemInstanceRemote(lb.ID)
|
||||
case "nats launcher":
|
||||
require.NoError(t, d.subscribeSystemReboot(lb.ID))
|
||||
err = elbv2domain.NewNATSSystemInstanceLauncher(d.natsConn, 5*time.Second).RebootSystemInstance(lb.ID)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Eventually(t, func() bool { return monitorDead(mon) }, 5*time.Second, 10*time.Millisecond, "the owner restarts the guest")
|
||||
d.vmMgr.WaitForBackgroundWork()
|
||||
assert.NotEqual(t, vm.StateTerminated, d.vmMgr.Status(lb))
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
assert.NotContains(t, moves, vm.StateShuttingDown, "a repair never terminates")
|
||||
assert.NotContains(t, moves, vm.StateTerminated, "a repair never terminates")
|
||||
assert.Empty(t, eip.released, "a repair never releases an address")
|
||||
assert.Empty(t, eip.reclaimed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebootSystemInstance_NoOwnerAndUnreadableRecord(t *testing.T) {
|
||||
t.Run("no owner subscribed", func(t *testing.T) {
|
||||
d := guardDaemon(t)
|
||||
err := d.rebootSystemInstanceRemote("i-nobody-runs-this")
|
||||
require.ErrorIs(t, err, systeminstance.ErrSystemInstanceNotFound)
|
||||
|
||||
err = elbv2domain.NewNATSSystemInstanceLauncher(d.natsConn, 2*time.Second).RebootSystemInstance("i-nobody-runs-this")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, nats.ErrNoResponders, "an unreachable owner stays distinguishable")
|
||||
})
|
||||
|
||||
t.Run("a record this node cannot read", func(t *testing.T) {
|
||||
d := guardDaemon(t)
|
||||
lb := &vm.VM{ID: "i-lb-unreadable", Status: vm.StateRunning, AccountID: awsidentifiers.GlobalAccountID, ManagedBy: tags.ManagedByELBv2}
|
||||
mon := withMonitor(lb)
|
||||
d.vmMgr.Insert(lb)
|
||||
d.ownership = fakeOwnership{err: errInjected}
|
||||
|
||||
err := d.RebootSystemInstance(lb.ID)
|
||||
require.ErrorIs(t, err, systeminstance.ErrSystemInstanceOwnershipUnknown)
|
||||
d.vmMgr.WaitForBackgroundWork()
|
||||
assert.False(t, monitorDead(mon))
|
||||
})
|
||||
|
||||
t.Run("a VM that is not running", func(t *testing.T) {
|
||||
d := guardDaemon(t)
|
||||
lb := &vm.VM{ID: "i-lb-stopped", Status: vm.StateStopped, AccountID: awsidentifiers.GlobalAccountID, ManagedBy: tags.ManagedByELBv2}
|
||||
stage(t, d, lb, atLive)
|
||||
require.Error(t, d.RebootSystemInstance(lb.ID))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRebootSystemInstanceRemote_ReplyAndConnectionFailures(t *testing.T) {
|
||||
t.Run("no connection reads as an unreachable owner", func(t *testing.T) {
|
||||
d := guardDaemon(t)
|
||||
d.natsConn = nil
|
||||
require.ErrorIs(t, d.rebootSystemInstanceRemote("i-lb"), systeminstance.ErrSystemInstanceNotFound)
|
||||
})
|
||||
|
||||
t.Run("an undecodable reply is an error, not a reboot", func(t *testing.T) {
|
||||
d := guardDaemon(t)
|
||||
sub, err := d.natsConn.Subscribe(systemRebootSubject("i-garbled"), func(msg *nats.Msg) {
|
||||
_ = msg.Respond([]byte("{not json"))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
|
||||
err = d.rebootSystemInstanceRemote("i-garbled")
|
||||
require.Error(t, err)
|
||||
assert.NotErrorIs(t, err, systeminstance.ErrSystemInstanceNotFound)
|
||||
})
|
||||
|
||||
t.Run("an owner that refuses is reported to the caller", func(t *testing.T) {
|
||||
d := guardDaemon(t)
|
||||
sub, err := d.natsConn.Subscribe(systemRebootSubject("i-refusing"), func(msg *nats.Msg) {
|
||||
respondWithSystemRebootReply(msg, "not a system instance")
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
|
||||
require.Error(t, d.rebootSystemInstanceRemote("i-refusing"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleSystemRebootInstance_RejectsAMalformedSubject(t *testing.T) {
|
||||
d := guardDaemon(t)
|
||||
sub, err := d.natsConn.Subscribe("system.Elsewhere.i-lb", d.handleSystemRebootInstance)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
|
||||
reply, err := d.natsConn.Request("system.Elsewhere.i-lb", nil, 2*time.Second)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(reply.Data), "InvalidInstanceID.NotFound")
|
||||
d.systemDispatchWg.Wait()
|
||||
}
|
||||
|
||||
func TestSubscribeSystemReboot_IdempotentAndSkippedWithoutConnection(t *testing.T) {
|
||||
d := guardDaemon(t)
|
||||
require.NoError(t, d.subscribeSystemReboot("i-lb"))
|
||||
require.NoError(t, d.subscribeSystemReboot("i-lb"))
|
||||
assert.Contains(t, d.natsSubscriptions, systemRebootSubject("i-lb"))
|
||||
|
||||
d.natsConn = nil
|
||||
require.NoError(t, d.subscribeSystemReboot("i-other"))
|
||||
assert.NotContains(t, d.natsSubscriptions, systemRebootSubject("i-other"))
|
||||
}
|
||||
@@ -739,10 +739,6 @@ func (d *Daemon) onInstanceUpHook() func(*vm.VM) error {
|
||||
slog.Error("OnInstanceUp: failed to re-arm system terminate subscription",
|
||||
"instanceId", instance.ID, "err", subErr)
|
||||
}
|
||||
if subErr := d.subscribeSystemRebootLocked(instance.ID); subErr != nil {
|
||||
slog.Error("OnInstanceUp: failed to re-arm system reboot subscription",
|
||||
"instanceId", instance.ID, "err", subErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-claim GPU(s) after a daemon restart with a still-running QEMU
|
||||
@@ -847,13 +843,6 @@ func (d *Daemon) onInstanceDownHook() func(string) {
|
||||
}
|
||||
delete(d.natsSubscriptions, terminateSubKey)
|
||||
}
|
||||
if sub, ok := d.natsSubscriptions[systemRebootSubject(instanceID)]; ok {
|
||||
if err := sub.Unsubscribe(); err != nil {
|
||||
slog.Error("OnInstanceDown: failed to unsubscribe system reboot topic",
|
||||
"instanceId", instanceID, "err", err)
|
||||
}
|
||||
delete(d.natsSubscriptions, systemRebootSubject(instanceID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -393,9 +393,7 @@ func launchService(config *config.ClusterConfig) error {
|
||||
}
|
||||
// Built but routed only in test-link builds: Lambda stays out of the public
|
||||
// router until its runtime image exists.
|
||||
lambdaMappings := lambdaMappingStore(natsConn)
|
||||
lambdaEventSources := newESMRuntime(lambdaMappings, stsService, nodeConfig.Region)
|
||||
lambdaDeps := lambdaawsapi.Deps{
|
||||
lambdaControlPlane := lambdaawsapi.NewRegistration(lambdaawsapi.Deps{
|
||||
Meta: lambdafunction.NewNATSClient(natsConn),
|
||||
Codes: func(ctx context.Context) (lambdaawsapi.CodeStore, error) {
|
||||
store, err := lambdaCodeStore.Store(ctx)
|
||||
@@ -408,12 +406,7 @@ func launchService(config *config.ClusterConfig) error {
|
||||
Trusts: lambdaRoleTrusts(iamService),
|
||||
AEAD: lambdaKey.AEAD,
|
||||
Exec: lambdaTestExecutor(natsConn, config.Node, lambdaCodeStore, stsService),
|
||||
Mappings: func(ctx context.Context) (lambdaawsapi.MappingStore, error) {
|
||||
return lambdaMappings(ctx)
|
||||
},
|
||||
Sources: lambdaEventSources.sources(),
|
||||
}
|
||||
lambdaControlPlane := lambdaawsapi.NewRegistration(lambdaDeps)
|
||||
})
|
||||
|
||||
// ECR auth bridge: load (or first-run create) the ES256 signing key from the
|
||||
// cluster-replicated awsgw-keys KV bucket, then build the token issuer
|
||||
@@ -786,7 +779,6 @@ func launchService(config *config.ClusterConfig) error {
|
||||
go quotaSvc.RunBedrockRPMSync(janitorCtx, js)
|
||||
|
||||
handler := gw.SetupRoutes()
|
||||
lambdaEventSources.start(janitorCtx, handler, lambdaDeps, sqsEndpoint(registryHost, registryPort))
|
||||
|
||||
// Load TLS certificate
|
||||
cert, err := tls.LoadX509KeyPair(nodeConfig.AWSGW.TLSCert, nodeConfig.AWSGW.TLSKey)
|
||||
|
||||
@@ -4,10 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/sts"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/eventsource"
|
||||
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// lambdaEnvironmentRoleIssuer is the in-process STS entry point the test-link
|
||||
@@ -18,14 +15,3 @@ type lambdaEnvironmentRoleIssuer interface {
|
||||
}
|
||||
|
||||
var _ lambdaEnvironmentRoleIssuer = (*stsdomain.STSServiceImpl)(nil)
|
||||
|
||||
// lambdaMappingStore opens the event-source mapping bucket on first use.
|
||||
func lambdaMappingStore(nc *nats.Conn) func(ctx context.Context) (*eventsource.Store, error) {
|
||||
return func(ctx context.Context) (*eventsource.Store, error) {
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return eventsource.Open(ctx, js)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,593 +0,0 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
package awsgw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/sqs"
|
||||
"github.com/aws/aws-sdk-go/service/sqs/sqsiface"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/eventsource"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/function"
|
||||
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
)
|
||||
|
||||
// Poller timing. The receive is a long poll, so an idle queue costs one
|
||||
// request per esmWaitSeconds; a session is reissued well before it expires.
|
||||
const (
|
||||
esmWaitSeconds = 20
|
||||
esmReconcileEvery = 5 * time.Second
|
||||
esmMaxBackoff = 60 * time.Second
|
||||
esmSessionSeconds = 900
|
||||
esmRenewBefore = 10 * time.Minute
|
||||
esmDeleteTimeout = 15 * time.Second
|
||||
// esmMaxEvent is the synchronous request bound one event must fit.
|
||||
esmMaxEvent = 6 * 1024 * 1024
|
||||
// esmMaxReason bounds a recorded failure reason.
|
||||
esmMaxReason = 512
|
||||
)
|
||||
|
||||
// esmInvoker runs one event through Invoke's checks, without Invoke's IAM check.
|
||||
type esmInvoker interface {
|
||||
InvokeEventSource(ctx context.Context, accountID, region, name string, payload []byte) (awsapi.SyncResult, error)
|
||||
}
|
||||
|
||||
var _ esmInvoker = awsapi.Deps{}
|
||||
|
||||
// esmStore is what the poller needs of the mapping store.
|
||||
type esmStore interface {
|
||||
Get(ctx context.Context, uuid string) (eventsource.Mapping, uint64, error)
|
||||
List(ctx context.Context) ([]eventsource.Entry, error)
|
||||
Update(ctx context.Context, m eventsource.Mapping, revision uint64) (uint64, error)
|
||||
Remove(ctx context.Context, uuid string, revision uint64) error
|
||||
}
|
||||
|
||||
var _ esmStore = (*eventsource.Store)(nil)
|
||||
|
||||
// esmRuntime polls every enabled SQS mapping on this gateway. It is one loop
|
||||
// per gateway with no cross-node lease, so it is correct on a single node only.
|
||||
type esmRuntime struct {
|
||||
store func(ctx context.Context) (esmStore, error)
|
||||
sessions lambdaEnvironmentRoleIssuer
|
||||
region string
|
||||
meta function.Client
|
||||
invoke esmInvoker
|
||||
// queue builds an SQS client signed as one session; nil means the
|
||||
// gateway handler, set by start.
|
||||
queue func(cred *credentials.Credentials) sqsiface.SQSAPI
|
||||
now func() time.Time
|
||||
// interval and wait pace reconcile and back-off; zero uses the defaults.
|
||||
interval time.Duration
|
||||
backoff time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
handler http.Handler
|
||||
// endpoint is the public SQS base URL queue URLs are built on.
|
||||
endpoint string
|
||||
}
|
||||
|
||||
func newESMRuntime(open func(ctx context.Context) (*eventsource.Store, error), sessions lambdaEnvironmentRoleIssuer, region string) *esmRuntime {
|
||||
store := func(ctx context.Context) (esmStore, error) { return open(ctx) }
|
||||
return &esmRuntime{store: store, sessions: sessions, region: region, now: time.Now}
|
||||
}
|
||||
|
||||
func (r *esmRuntime) sources() awsapi.SourceChecker { return r }
|
||||
|
||||
// start runs the poller until ctx ends; deps is the composed Lambda control plane.
|
||||
func (r *esmRuntime) start(ctx context.Context, h http.Handler, deps awsapi.Deps, sqsEndpoint string) {
|
||||
r.mu.Lock()
|
||||
r.handler = h
|
||||
r.endpoint = sqsEndpoint
|
||||
if r.queue == nil {
|
||||
r.queue = r.handlerQueue
|
||||
}
|
||||
r.meta = deps.Meta
|
||||
r.invoke = deps
|
||||
r.mu.Unlock()
|
||||
go r.run(ctx)
|
||||
}
|
||||
|
||||
// handlerTransport carries a signed SDK request straight into the gateway
|
||||
// handler. It never leaves the TLS-terminating gateway, so it is marked as
|
||||
// received over TLS and is authorized exactly as one from a client.
|
||||
type handlerTransport struct{ h http.Handler }
|
||||
|
||||
func (t handlerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
in := req.Clone(req.Context())
|
||||
in.TLS = &tls.ConnectionState{Version: tls.VersionTLS13, HandshakeComplete: true}
|
||||
in.RemoteAddr = "127.0.0.1:0"
|
||||
in.RequestURI = req.URL.RequestURI()
|
||||
if in.Host == "" {
|
||||
in.Host = req.URL.Host
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
t.h.ServeHTTP(rec, in)
|
||||
return rec.Result(), nil
|
||||
}
|
||||
|
||||
func (r *esmRuntime) handlerQueue(cred *credentials.Credentials) sqsiface.SQSAPI {
|
||||
r.mu.Lock()
|
||||
h, endpoint := r.handler, r.endpoint
|
||||
r.mu.Unlock()
|
||||
sess := session.Must(session.NewSession(&aws.Config{
|
||||
Region: aws.String(r.region),
|
||||
Endpoint: aws.String(endpoint),
|
||||
Credentials: cred,
|
||||
HTTPClient: &http.Client{Transport: handlerTransport{h: h}},
|
||||
MaxRetries: aws.Int(0),
|
||||
}))
|
||||
return sqs.New(sess)
|
||||
}
|
||||
|
||||
// esmSession is one execution-role session the poller signs with.
|
||||
type esmSession struct {
|
||||
cred *credentials.Credentials
|
||||
keyID string
|
||||
role string
|
||||
expires time.Time
|
||||
scope stsdomain.LambdaEnvironmentSessionScope
|
||||
}
|
||||
|
||||
func (r *esmRuntime) issue(ctx context.Context, rec function.Record) (*esmSession, error) {
|
||||
b := make([]byte, 6)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope := stsdomain.LambdaEnvironmentSessionScope{
|
||||
AccountID: rec.AccountID,
|
||||
FunctionID: rec.FunctionID,
|
||||
FunctionARN: arn.FormatLambdaFunction(rec.Region, rec.AccountID, rec.FunctionName),
|
||||
EnvironmentID: "esm-" + hex.EncodeToString(b),
|
||||
}
|
||||
out, err := r.sessions.AssumeRoleForLambdaEnvironment(ctx, stsdomain.LambdaEnvironmentSessionRequest{
|
||||
AccountID: scope.AccountID, RoleARN: rec.Role, FunctionARN: scope.FunctionARN,
|
||||
FunctionID: scope.FunctionID, EnvironmentID: scope.EnvironmentID, MaxLifetimeSeconds: esmSessionSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := out.Credentials
|
||||
return &esmSession{
|
||||
cred: credentials.NewStaticCredentials(aws.StringValue(c.AccessKeyId), aws.StringValue(c.SecretAccessKey),
|
||||
aws.StringValue(c.SessionToken)),
|
||||
keyID: aws.StringValue(c.AccessKeyId), role: rec.Role, expires: aws.TimeValue(c.Expiration), scope: scope,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *esmRuntime) revoke(s *esmSession) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), revokeTimeout)
|
||||
defer cancel()
|
||||
if err := r.sessions.RevokeLambdaEnvironmentSession(ctx, s.keyID, s.scope); err != nil {
|
||||
slog.Warn("Lambda event source: poller session not revoked; it stays valid until expiry",
|
||||
"environment_id", s.scope.EnvironmentID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fresh returns s while it is the function's role and outlives need;
|
||||
// otherwise it issues a replacement and revokes s, which no request holds.
|
||||
func (r *esmRuntime) fresh(ctx context.Context, rec function.Record, s *esmSession, need time.Duration) (*esmSession, error) {
|
||||
if s != nil && s.role == rec.Role && s.expires.Sub(r.now()) > need {
|
||||
return s, nil
|
||||
}
|
||||
next, err := r.issue(ctx, rec)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
r.revoke(s)
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// eventClient renews *s before each event unless it outlives that event's
|
||||
// invocation and delete, so no acknowledgement runs on expired credentials.
|
||||
func (r *esmRuntime) eventClient(ctx context.Context, rec function.Record, s **esmSession) func() (sqsiface.SQSAPI, error) {
|
||||
need := time.Duration(rec.Timeout)*time.Second + esmDeleteTimeout + time.Minute
|
||||
return func() (sqsiface.SQSAPI, error) {
|
||||
next, err := r.fresh(ctx, rec, *s, need)
|
||||
*s = next
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.queue(next.cred), nil
|
||||
}
|
||||
}
|
||||
|
||||
func queueName(queueARN string) (account, name string) {
|
||||
parts := strings.Split(queueARN, ":")
|
||||
if len(parts) != 6 {
|
||||
return "", ""
|
||||
}
|
||||
return parts[4], parts[5]
|
||||
}
|
||||
|
||||
// queueURL builds the queue's URL rather than asking GetQueueUrl, which the
|
||||
// execution role is not required to allow.
|
||||
func (r *esmRuntime) queueURL(queueARN string) (string, error) {
|
||||
r.mu.Lock()
|
||||
endpoint := r.endpoint
|
||||
r.mu.Unlock()
|
||||
account, name := queueName(queueARN)
|
||||
if endpoint == "" || name == "" {
|
||||
return "", errors.New("SQS is not served on this gateway")
|
||||
}
|
||||
return endpoint + "/" + account + "/" + name, nil
|
||||
}
|
||||
|
||||
// QueueVisibilityTimeout reads the queue as the function's execution role
|
||||
// with a short-lived session it revokes before returning.
|
||||
func (r *esmRuntime) QueueVisibilityTimeout(ctx context.Context, rec function.Record, queueARN string) (int64, error) {
|
||||
s, err := r.issue(ctx, rec)
|
||||
if err != nil {
|
||||
if code, ok := awserrors.ResolveErrorCode(err); ok && code == awserrors.ErrorAccessDenied {
|
||||
return 0, awserrors.Errorf(awserrors.ErrorInvalidParameterValueException, "The role defined for the function cannot be assumed by Lambda.")
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
defer r.revoke(s)
|
||||
q := r.queue(s.cred)
|
||||
url, err := r.queueURL(queueARN)
|
||||
if err != nil {
|
||||
return 0, awserrors.Errorf(awserrors.ErrorServiceException, "%s", err.Error())
|
||||
}
|
||||
out, err := q.GetQueueAttributesWithContext(ctx, &sqs.GetQueueAttributesInput{
|
||||
QueueUrl: aws.String(url), AttributeNames: aws.StringSlice([]string{sqs.QueueAttributeNameVisibilityTimeout}),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, sourceError(err)
|
||||
}
|
||||
var v int64
|
||||
if _, err := fmt.Sscan(aws.StringValue(out.Attributes[sqs.QueueAttributeNameVisibilityTimeout]), &v); err != nil {
|
||||
return 0, fmt.Errorf("queue visibility timeout: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// sourceError reports a queue the execution role cannot read as the
|
||||
// caller's fault, as Lambda does.
|
||||
func sourceError(err error) error {
|
||||
var ae awserr.Error
|
||||
if !errors.As(err, &ae) {
|
||||
return err
|
||||
}
|
||||
switch ae.Code() {
|
||||
case "AccessDenied", "AccessDeniedException":
|
||||
return awserrors.Errorf(awserrors.ErrorInvalidParameterValueException,
|
||||
"The function execution role does not have permissions to call GetQueueAttributes on SQS")
|
||||
case sqs.ErrCodeQueueDoesNotExist, "QueueDoesNotExist":
|
||||
return awserrors.Errorf(awserrors.ErrorInvalidParameterValueException,
|
||||
"Error occurred while ReceiveMessage. SQS Error Code: AWS.SimpleQueueService.NonExistentQueue. SQS Error Message: The specified queue does not exist.")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type esmWorker struct {
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (w *esmWorker) stop() {
|
||||
w.cancel()
|
||||
<-w.done
|
||||
}
|
||||
|
||||
func (r *esmRuntime) run(ctx context.Context) {
|
||||
workers := map[string]*esmWorker{}
|
||||
defer func() {
|
||||
for _, w := range workers {
|
||||
w.stop()
|
||||
}
|
||||
}()
|
||||
every := r.interval
|
||||
if every == 0 {
|
||||
every = esmReconcileEvery
|
||||
}
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
r.reconcile(ctx, workers)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reconcile starts a worker for each enabled mapping, including those
|
||||
// written before a restart, and finishes each delete only after its worker
|
||||
// has stopped, so no invoke or message delete follows it.
|
||||
func (r *esmRuntime) reconcile(ctx context.Context, workers map[string]*esmWorker) {
|
||||
store, err := r.store(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("Lambda event source: mapping store unavailable", "err", err)
|
||||
return
|
||||
}
|
||||
entries, err := store.List(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("Lambda event source: list mappings failed", "err", err)
|
||||
return
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
m := e.Mapping
|
||||
seen[m.UUID] = true
|
||||
switch m.State {
|
||||
case eventsource.StateEnabled:
|
||||
if workers[m.UUID] == nil {
|
||||
wctx, cancel := context.WithCancel(ctx)
|
||||
w := &esmWorker{cancel: cancel, done: make(chan struct{})}
|
||||
workers[m.UUID] = w
|
||||
go func() {
|
||||
defer close(w.done)
|
||||
r.work(wctx, m)
|
||||
}()
|
||||
}
|
||||
case eventsource.StateDeleting:
|
||||
if w := workers[m.UUID]; w != nil {
|
||||
w.stop()
|
||||
delete(workers, m.UUID)
|
||||
}
|
||||
if err := r.finishDelete(ctx, store, m.UUID); err != nil {
|
||||
slog.Warn("Lambda event source: mapping not removed; retrying", "uuid", m.UUID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for id, w := range workers {
|
||||
if !seen[id] {
|
||||
w.stop()
|
||||
delete(workers, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *esmRuntime) finishDelete(ctx context.Context, store esmStore, id string) error {
|
||||
m, rev, err := store.Get(ctx, id)
|
||||
if errors.Is(err, eventsource.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m.State != eventsource.StateDeleting {
|
||||
return nil
|
||||
}
|
||||
return store.Remove(ctx, id, rev)
|
||||
}
|
||||
|
||||
// setReason records a receive failure on an enabled mapping, or clears it
|
||||
// back to USER_INITIATED with reason "".
|
||||
func (r *esmRuntime) setReason(ctx context.Context, id, reason string) {
|
||||
if reason == "" {
|
||||
reason = eventsource.ReasonUserInitiated
|
||||
} else {
|
||||
reason = "PROBLEM: " + reason
|
||||
if len(reason) > esmMaxReason {
|
||||
reason = reason[:esmMaxReason]
|
||||
}
|
||||
}
|
||||
store, err := r.store(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m, rev, err := store.Get(ctx, id)
|
||||
if err != nil || m.State != eventsource.StateEnabled || m.StateTransitionReason == reason {
|
||||
return
|
||||
}
|
||||
m.StateTransitionReason = reason
|
||||
if _, err := store.Update(ctx, m, rev); err != nil && !errors.Is(err, eventsource.ErrConflict) {
|
||||
slog.Warn("Lambda event source: reason not recorded", "uuid", id, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *esmRuntime) wait(ctx context.Context, d time.Duration) {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
|
||||
// work polls one mapping until ctx ends. A failure keeps the mapping, records
|
||||
// why, and retries after a back-off.
|
||||
func (r *esmRuntime) work(ctx context.Context, m eventsource.Mapping) {
|
||||
var s *esmSession
|
||||
defer func() { r.revoke(s) }()
|
||||
base := r.backoff
|
||||
if base == 0 {
|
||||
base = time.Second
|
||||
}
|
||||
backoff := base
|
||||
fail := func(reason string) {
|
||||
r.setReason(context.WithoutCancel(ctx), m.UUID, reason)
|
||||
r.wait(ctx, backoff)
|
||||
backoff = min(2*backoff, esmMaxBackoff)
|
||||
}
|
||||
for ctx.Err() == nil {
|
||||
rec, _, err := r.meta.Get(ctx, m.AccountID, m.Region, m.FunctionName)
|
||||
if err != nil {
|
||||
fail("Function lookup failed: " + err.Error())
|
||||
continue
|
||||
}
|
||||
next, err := r.fresh(ctx, rec, s, esmRenewBefore)
|
||||
s = next
|
||||
if err != nil {
|
||||
fail("The execution role could not be assumed: " + err.Error())
|
||||
continue
|
||||
}
|
||||
q := r.queue(s.cred)
|
||||
url, err := r.queueURL(m.EventSourceARN)
|
||||
if err != nil {
|
||||
fail("Error occurred while ReceiveMessage: " + err.Error())
|
||||
continue
|
||||
}
|
||||
out, err := q.ReceiveMessageWithContext(ctx, &sqs.ReceiveMessageInput{
|
||||
QueueUrl: aws.String(url),
|
||||
MaxNumberOfMessages: aws.Int64(m.BatchSize),
|
||||
WaitTimeSeconds: aws.Int64(esmWaitSeconds),
|
||||
AttributeNames: aws.StringSlice([]string{"All"}),
|
||||
MessageAttributeNames: aws.StringSlice([]string{"All"}),
|
||||
})
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
fail("Error occurred while ReceiveMessage: " + err.Error())
|
||||
}
|
||||
continue
|
||||
}
|
||||
r.setReason(ctx, m.UUID, "")
|
||||
if err := r.process(ctx, m, r.eventClient(ctx, rec, &s), url, out.Messages); err != nil {
|
||||
slog.Warn("Lambda event source: batch not processed; messages stay on the queue", "uuid", m.UUID, "err", err)
|
||||
r.wait(ctx, backoff)
|
||||
backoff = min(2*backoff, esmMaxBackoff)
|
||||
continue
|
||||
}
|
||||
backoff = base
|
||||
}
|
||||
}
|
||||
|
||||
// sqsRecord and sqsAttribute are the AWS SQS event record shape.
|
||||
type sqsRecord struct {
|
||||
MessageID string `json:"messageId"`
|
||||
ReceiptHandle string `json:"receiptHandle"`
|
||||
Body string `json:"body"`
|
||||
Attributes map[string]string `json:"attributes"`
|
||||
MessageAttributes map[string]sqsAttribute `json:"messageAttributes"`
|
||||
MD5OfBody string `json:"md5OfBody"`
|
||||
EventSource string `json:"eventSource"`
|
||||
EventSourceARN string `json:"eventSourceARN"`
|
||||
AWSRegion string `json:"awsRegion"`
|
||||
}
|
||||
|
||||
type sqsAttribute struct {
|
||||
StringValue *string `json:"stringValue,omitempty"`
|
||||
BinaryValue []byte `json:"binaryValue,omitempty"`
|
||||
StringListValues []string `json:"stringListValues"`
|
||||
BinaryListValues [][]byte `json:"binaryListValues"`
|
||||
DataType string `json:"dataType"`
|
||||
}
|
||||
|
||||
func eventRecord(m eventsource.Mapping, msg *sqs.Message) sqsRecord {
|
||||
rec := sqsRecord{
|
||||
MessageID: aws.StringValue(msg.MessageId), ReceiptHandle: aws.StringValue(msg.ReceiptHandle),
|
||||
Body: aws.StringValue(msg.Body), Attributes: map[string]string{}, MessageAttributes: map[string]sqsAttribute{},
|
||||
MD5OfBody: aws.StringValue(msg.MD5OfBody), EventSource: "aws:sqs", EventSourceARN: m.EventSourceARN, AWSRegion: m.Region,
|
||||
}
|
||||
for k, v := range msg.Attributes {
|
||||
rec.Attributes[k] = aws.StringValue(v)
|
||||
}
|
||||
for k, v := range msg.MessageAttributes {
|
||||
rec.MessageAttributes[k] = sqsAttribute{
|
||||
StringValue: v.StringValue, BinaryValue: v.BinaryValue, StringListValues: []string{},
|
||||
BinaryListValues: [][]byte{}, DataType: aws.StringValue(v.DataType),
|
||||
}
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
// esmEvent is one invocation's payload and the messages it carries.
|
||||
type esmEvent struct {
|
||||
Payload []byte
|
||||
Messages []*sqs.Message
|
||||
}
|
||||
|
||||
// splitEvents groups messages into events whose encoding fits esmMaxEvent.
|
||||
// A message too large for an event of its own is returned in oversized and
|
||||
// is neither invoked nor deleted.
|
||||
func splitEvents(m eventsource.Mapping, msgs []*sqs.Message) (events []esmEvent, oversized []*sqs.Message) {
|
||||
const frame = len(`{"Records":[]}`)
|
||||
var recs []json.RawMessage
|
||||
var batch []*sqs.Message
|
||||
size := frame
|
||||
flush := func() {
|
||||
if len(recs) == 0 {
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(struct {
|
||||
Records []json.RawMessage `json:"Records"`
|
||||
}{recs})
|
||||
events = append(events, esmEvent{Payload: payload, Messages: batch})
|
||||
recs, batch, size = nil, nil, frame
|
||||
}
|
||||
for _, msg := range msgs {
|
||||
enc, err := json.Marshal(eventRecord(m, msg))
|
||||
if err != nil || frame+len(enc) > esmMaxEvent {
|
||||
oversized = append(oversized, msg)
|
||||
continue
|
||||
}
|
||||
extra := len(enc)
|
||||
if len(recs) > 0 {
|
||||
extra++
|
||||
}
|
||||
if size+extra > esmMaxEvent {
|
||||
flush()
|
||||
extra = len(enc)
|
||||
}
|
||||
recs = append(recs, enc)
|
||||
batch = append(batch, msg)
|
||||
size += extra
|
||||
}
|
||||
flush()
|
||||
return events, oversized
|
||||
}
|
||||
|
||||
// process invokes each event and deletes its messages only on a classified
|
||||
// success with no function error; anything else leaves them to reappear.
|
||||
// process invokes and acknowledges each event in turn. It returns the first
|
||||
// failure, so the worker backs off before receiving again.
|
||||
func (r *esmRuntime) process(ctx context.Context, m eventsource.Mapping, client func() (sqsiface.SQSAPI, error), url string, msgs []*sqs.Message) error {
|
||||
events, oversized := splitEvents(m, msgs)
|
||||
if len(oversized) > 0 {
|
||||
r.setReason(ctx, m.UUID, fmt.Sprintf("%d message(s) exceed the %d-byte event limit and are not delivered", len(oversized), esmMaxEvent))
|
||||
}
|
||||
for _, ev := range events {
|
||||
// A stopped worker invokes nothing further; delete waits for it.
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
q, err := client()
|
||||
if err != nil {
|
||||
return fmt.Errorf("execution role session: %w", err)
|
||||
}
|
||||
res, err := r.invoke.InvokeEventSource(ctx, m.AccountID, m.Region, m.FunctionName, ev.Payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.FunctionError != "" {
|
||||
return fmt.Errorf("function error %s", res.FunctionError)
|
||||
}
|
||||
entries := make([]*sqs.DeleteMessageBatchRequestEntry, len(ev.Messages))
|
||||
for i, msg := range ev.Messages {
|
||||
entries[i] = &sqs.DeleteMessageBatchRequestEntry{Id: aws.String(fmt.Sprint(i)), ReceiptHandle: msg.ReceiptHandle}
|
||||
}
|
||||
dctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), esmDeleteTimeout)
|
||||
out, err := q.DeleteMessageBatchWithContext(dctx, &sqs.DeleteMessageBatchInput{QueueUrl: aws.String(url), Entries: entries})
|
||||
cancel()
|
||||
if err != nil {
|
||||
slog.Warn("Lambda event source: processed messages not deleted; they will be delivered again", "uuid", m.UUID, "err", err)
|
||||
} else if len(out.Failed) > 0 {
|
||||
slog.Warn("Lambda event source: some processed messages not deleted", "uuid", m.UUID, "failed", len(out.Failed))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//go:build !lambdatestlink
|
||||
|
||||
package awsgw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/eventsource"
|
||||
)
|
||||
|
||||
// Without the test link no mapping is polled and CreateEventSourceMapping has
|
||||
// no queue reader, so it refuses; Lambda is unrouted there anyway.
|
||||
type esmRuntime struct{}
|
||||
|
||||
func newESMRuntime(func(context.Context) (*eventsource.Store, error), lambdaEnvironmentRoleIssuer, string) *esmRuntime {
|
||||
return &esmRuntime{}
|
||||
}
|
||||
|
||||
func (*esmRuntime) sources() awsapi.SourceChecker { return nil }
|
||||
|
||||
func (*esmRuntime) start(context.Context, http.Handler, awsapi.Deps, string) {}
|
||||
@@ -1,517 +0,0 @@
|
||||
//go:build lambdatestlink
|
||||
|
||||
//test:in-package — the poller, its session handling and its in-process
|
||||
// transport are unexported test-link internals.
|
||||
package awsgw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/request"
|
||||
"github.com/aws/aws-sdk-go/service/sqs"
|
||||
"github.com/aws/aws-sdk-go/service/sqs/sqsiface"
|
||||
"github.com/aws/aws-sdk-go/service/sts"
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
testutil "github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/eventsource"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/lambda/function"
|
||||
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/credential"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/lambda/testlink"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/policyauth"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const esmQueueARN = "arn:aws:sqs:ap-southeast-2:123456789012:in"
|
||||
|
||||
// esmQueue is an SQS fake: Receive hands out queued messages once, Delete
|
||||
// records what was deleted.
|
||||
type esmQueue struct {
|
||||
sqsiface.SQSAPI
|
||||
mu sync.Mutex
|
||||
pending []*sqs.Message
|
||||
deleted []string
|
||||
receives int
|
||||
receiveErr error
|
||||
urls []string
|
||||
// redeliver keeps received messages pending, as an expired visibility would.
|
||||
redeliver bool
|
||||
}
|
||||
|
||||
func (q *esmQueue) ReceiveMessageWithContext(ctx aws.Context, in *sqs.ReceiveMessageInput, _ ...request.Option) (*sqs.ReceiveMessageOutput, error) {
|
||||
q.mu.Lock()
|
||||
q.receives++
|
||||
q.urls = append(q.urls, aws.StringValue(in.QueueUrl))
|
||||
err := q.receiveErr
|
||||
n := min(int(aws.Int64Value(in.MaxNumberOfMessages)), len(q.pending))
|
||||
msgs := q.pending[:n]
|
||||
if !q.redeliver {
|
||||
q.pending = q.pending[n:]
|
||||
}
|
||||
q.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
t := time.NewTimer(20 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
return &sqs.ReceiveMessageOutput{Messages: msgs}, nil
|
||||
}
|
||||
|
||||
func (q *esmQueue) DeleteMessageBatchWithContext(_ aws.Context, in *sqs.DeleteMessageBatchInput, _ ...request.Option) (*sqs.DeleteMessageBatchOutput, error) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
for _, e := range in.Entries {
|
||||
q.deleted = append(q.deleted, aws.StringValue(e.ReceiptHandle))
|
||||
}
|
||||
return &sqs.DeleteMessageBatchOutput{}, nil
|
||||
}
|
||||
|
||||
func (q *esmQueue) state() (deleted []string, receives int) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return append([]string(nil), q.deleted...), q.receives
|
||||
}
|
||||
|
||||
// esmInvokes records each event and returns the configured outcome.
|
||||
type esmInvokes struct {
|
||||
mu sync.Mutex
|
||||
payloads []string
|
||||
result awsapi.SyncResult
|
||||
err error
|
||||
// started, when set, is closed as the first call begins, which then
|
||||
// blocks until release is closed.
|
||||
started, release chan struct{}
|
||||
// during, when set, runs inside every invocation.
|
||||
during func()
|
||||
}
|
||||
|
||||
func (f *esmInvokes) InvokeEventSource(_ context.Context, _, _, _ string, payload []byte) (awsapi.SyncResult, error) {
|
||||
f.mu.Lock()
|
||||
f.payloads = append(f.payloads, string(payload))
|
||||
started, release, during := f.started, f.release, f.during
|
||||
f.started = nil
|
||||
f.mu.Unlock()
|
||||
if during != nil {
|
||||
during()
|
||||
}
|
||||
if started != nil {
|
||||
close(started)
|
||||
<-release
|
||||
}
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func (f *esmInvokes) count() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.payloads)
|
||||
}
|
||||
|
||||
// esmSessions issues numbered sessions with a fixed lifetime.
|
||||
type esmSessions struct {
|
||||
mu sync.Mutex
|
||||
life time.Duration
|
||||
issued int
|
||||
revoked []string
|
||||
issueErr error
|
||||
}
|
||||
|
||||
func (f *esmSessions) AssumeRoleForLambdaEnvironment(_ context.Context, _ stsdomain.LambdaEnvironmentSessionRequest) (*sts.AssumeRoleOutput, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.issueErr != nil {
|
||||
return nil, f.issueErr
|
||||
}
|
||||
f.issued++
|
||||
id := "ASIA" + strings.Repeat("K", f.issued)
|
||||
return &sts.AssumeRoleOutput{Credentials: &sts.Credentials{
|
||||
AccessKeyId: aws.String(id), SecretAccessKey: aws.String("s"), SessionToken: aws.String("t"),
|
||||
Expiration: aws.Time(time.Now().Add(f.life)),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (f *esmSessions) RevokeLambdaEnvironmentSession(_ context.Context, keyID string, _ stsdomain.LambdaEnvironmentSessionScope) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.revoked = append(f.revoked, keyID)
|
||||
return nil
|
||||
}
|
||||
|
||||
type esmMeta struct{ function.Client }
|
||||
|
||||
func (esmMeta) Get(_ context.Context, accountID, region, name string) (function.Record, uint64, error) {
|
||||
return function.Record{FunctionID: "fn1", AccountID: accountID, Region: region, FunctionName: name,
|
||||
Role: "arn:aws:iam::123456789012:role/fn-exec", Timeout: 3}, 1, nil
|
||||
}
|
||||
|
||||
func esmMapping(id string) eventsource.Mapping {
|
||||
return eventsource.Mapping{
|
||||
FormatVersion: eventsource.FormatVersion, UUID: id, AccountID: "123456789012", Region: "ap-southeast-2",
|
||||
FunctionName: "f", EventSourceARN: esmQueueARN, BatchSize: 10, State: eventsource.StateEnabled,
|
||||
StateTransitionReason: eventsource.ReasonUserInitiated,
|
||||
}
|
||||
}
|
||||
|
||||
func msg(id, body string) *sqs.Message {
|
||||
return &sqs.Message{MessageId: aws.String(id), ReceiptHandle: aws.String("rh-" + id), Body: aws.String(body)}
|
||||
}
|
||||
|
||||
func newTestESM(t *testing.T, q *esmQueue, inv *esmInvokes) (*esmRuntime, *eventsource.Store, *esmSessions) {
|
||||
t.Helper()
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
store, err := eventsource.Open(context.Background(), js)
|
||||
require.NoError(t, err)
|
||||
sessions := &esmSessions{life: time.Hour}
|
||||
r := newESMRuntime(func(context.Context) (*eventsource.Store, error) { return store, nil }, sessions, "ap-southeast-2")
|
||||
r.meta = esmMeta{}
|
||||
r.invoke = inv
|
||||
r.queue = func(*credentials.Credentials) sqsiface.SQSAPI { return q }
|
||||
r.endpoint = "https://gw.example:9999"
|
||||
r.interval = 20 * time.Millisecond
|
||||
r.backoff = 10 * time.Millisecond
|
||||
return r, store, sessions
|
||||
}
|
||||
|
||||
// Verifies: ADR-0009:S16
|
||||
func TestESM_DeletesOnlyOnClassifiedSuccess(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result awsapi.SyncResult
|
||||
err error
|
||||
delete bool
|
||||
}{
|
||||
{name: "success", result: awsapi.SyncResult{ExecutedVersion: "$LATEST", Payload: []byte(`{}`)}, delete: true},
|
||||
{name: "function error", result: awsapi.SyncResult{ExecutedVersion: "$LATEST", FunctionError: "Unhandled"}},
|
||||
{name: "refusal", err: &awsapi.RefusalError{Err: errors.New("node busy")}},
|
||||
{name: "unknown outcome", err: errors.New("the invocation outcome is unknown")},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
q := &esmQueue{}
|
||||
inv := &esmInvokes{result: tc.result, err: tc.err}
|
||||
r, _, _ := newTestESM(t, q, inv)
|
||||
err := r.process(context.Background(), esmMapping("u1"), func() (sqsiface.SQSAPI, error) { return q, nil }, "url",
|
||||
[]*sqs.Message{msg("1", "a"), msg("2", "b")})
|
||||
require.Equal(t, tc.delete, err == nil, "only a processed batch reports success")
|
||||
require.Equal(t, 1, inv.count())
|
||||
deleted, _ := q.state()
|
||||
if tc.delete {
|
||||
require.Equal(t, []string{"rh-1", "rh-2"}, deleted)
|
||||
} else {
|
||||
require.Empty(t, deleted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestESM_EventShapeAndSizeSplit(t *testing.T) {
|
||||
m := esmMapping("u1")
|
||||
quoted := strings.Repeat(`"`, 700*1024) // each byte encodes as two
|
||||
msgs := []*sqs.Message{}
|
||||
for i := range 6 {
|
||||
msgs = append(msgs, msg(string(rune('a'+i)), quoted))
|
||||
}
|
||||
events, oversized := splitEvents(m, msgs)
|
||||
require.Empty(t, oversized)
|
||||
require.Len(t, events, 2, "8.4 MiB of encoded records must split under the 6 MiB bound")
|
||||
total := 0
|
||||
for _, ev := range events {
|
||||
require.LessOrEqual(t, len(ev.Payload), esmMaxEvent)
|
||||
total += len(ev.Messages)
|
||||
}
|
||||
require.Equal(t, 6, total)
|
||||
require.Contains(t, string(events[0].Payload), `"eventSource":"aws:sqs"`)
|
||||
require.Contains(t, string(events[0].Payload), `"eventSourceARN":"`+esmQueueARN+`"`)
|
||||
require.Contains(t, string(events[0].Payload), `"messageAttributes":{}`)
|
||||
|
||||
huge := strings.Repeat("\x00", esmMaxEvent/6+1) // each byte encodes as \u0000
|
||||
events, oversized = splitEvents(m, []*sqs.Message{msg("big", huge), msg("ok", "small")})
|
||||
require.Len(t, oversized, 1)
|
||||
require.Equal(t, "big", aws.StringValue(oversized[0].MessageId))
|
||||
require.Len(t, events, 1)
|
||||
|
||||
q := &esmQueue{}
|
||||
inv := &esmInvokes{result: awsapi.SyncResult{ExecutedVersion: "$LATEST"}}
|
||||
r, _, _ := newTestESM(t, q, inv)
|
||||
require.NoError(t, r.process(context.Background(), m, func() (sqsiface.SQSAPI, error) { return q, nil }, "url", []*sqs.Message{msg("big", huge)}))
|
||||
require.Zero(t, inv.count(), "an oversized message is never invoked")
|
||||
deleted, _ := q.state()
|
||||
require.Empty(t, deleted, "an oversized message is never deleted")
|
||||
}
|
||||
|
||||
// A mapping written before the gateway starts is polled, and a Deleting
|
||||
// record is removed only after its worker stopped; nothing follows it.
|
||||
func TestESM_RestartReloadAndDeleteStopsWorker(t *testing.T) {
|
||||
q := &esmQueue{pending: []*sqs.Message{msg("1", "hello")}}
|
||||
inv := &esmInvokes{result: awsapi.SyncResult{ExecutedVersion: "$LATEST"}}
|
||||
r, store, _ := newTestESM(t, q, inv)
|
||||
ctx := t.Context()
|
||||
require.NoError(t, store.Create(ctx, esmMapping("u1")))
|
||||
|
||||
go r.run(ctx)
|
||||
require.Eventually(t, func() bool { d, _ := q.state(); return len(d) == 1 }, 5*time.Second, 10*time.Millisecond)
|
||||
_, receives := q.state()
|
||||
require.Positive(t, receives)
|
||||
q.mu.Lock()
|
||||
firstURL := q.urls[0]
|
||||
q.mu.Unlock()
|
||||
require.Equal(t, "https://gw.example:9999/123456789012/in", firstURL)
|
||||
|
||||
m, rev, err := store.Get(ctx, "u1")
|
||||
require.NoError(t, err)
|
||||
m.State = eventsource.StateDeleting
|
||||
_, err = store.Update(ctx, m, rev)
|
||||
require.NoError(t, err)
|
||||
require.Eventually(t, func() bool {
|
||||
_, _, err := store.Get(ctx, "u1")
|
||||
return errors.Is(err, eventsource.ErrNotFound)
|
||||
}, 5*time.Second, 10*time.Millisecond)
|
||||
|
||||
_, after := q.state()
|
||||
q.mu.Lock()
|
||||
q.pending = []*sqs.Message{msg("2", "late")}
|
||||
q.mu.Unlock()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
deleted, receivesNow := q.state()
|
||||
require.Equal(t, after, receivesNow, "no receive after the delete completed")
|
||||
require.Equal(t, []string{"rh-1"}, deleted)
|
||||
require.Equal(t, 1, inv.count())
|
||||
}
|
||||
|
||||
// A delete requested while a batch is running completes only after that batch
|
||||
// finished, so its message delete precedes the record's removal.
|
||||
func TestESM_DeleteWaitsForInFlightBatch(t *testing.T) {
|
||||
q := &esmQueue{pending: []*sqs.Message{msg("1", "hello")}}
|
||||
inv := &esmInvokes{result: awsapi.SyncResult{ExecutedVersion: "$LATEST"},
|
||||
started: make(chan struct{}), release: make(chan struct{})}
|
||||
started := inv.started
|
||||
r, store, _ := newTestESM(t, q, inv)
|
||||
ctx := t.Context()
|
||||
require.NoError(t, store.Create(ctx, esmMapping("u4")))
|
||||
go r.run(ctx)
|
||||
<-started
|
||||
|
||||
m, rev, err := store.Get(ctx, "u4")
|
||||
require.NoError(t, err)
|
||||
m.State = eventsource.StateDeleting
|
||||
_, err = store.Update(ctx, m, rev)
|
||||
require.NoError(t, err)
|
||||
require.Never(t, func() bool {
|
||||
_, _, err := store.Get(ctx, "u4")
|
||||
return errors.Is(err, eventsource.ErrNotFound)
|
||||
}, 300*time.Millisecond, 10*time.Millisecond, "the delete must wait for the running batch")
|
||||
|
||||
close(inv.release)
|
||||
require.Eventually(t, func() bool {
|
||||
_, _, err := store.Get(ctx, "u4")
|
||||
return errors.Is(err, eventsource.ErrNotFound)
|
||||
}, 5*time.Second, 10*time.Millisecond)
|
||||
deleted, _ := q.state()
|
||||
require.Equal(t, []string{"rh-1"}, deleted, "the finished batch's message was deleted before removal")
|
||||
}
|
||||
|
||||
// A Deleting record left by a crash before the gateway restarted is removed.
|
||||
func TestESM_InterruptedDeleteResumes(t *testing.T) {
|
||||
q := &esmQueue{}
|
||||
r, store, _ := newTestESM(t, q, &esmInvokes{})
|
||||
ctx := t.Context()
|
||||
m := esmMapping("u2")
|
||||
m.State = eventsource.StateDeleting
|
||||
require.NoError(t, store.Create(ctx, m))
|
||||
go r.run(ctx)
|
||||
require.Eventually(t, func() bool {
|
||||
_, _, err := store.Get(ctx, "u2")
|
||||
return errors.Is(err, eventsource.ErrNotFound)
|
||||
}, 5*time.Second, 10*time.Millisecond)
|
||||
_, receives := q.state()
|
||||
require.Zero(t, receives)
|
||||
}
|
||||
|
||||
func TestESM_SessionReissuedBeforeExpiry(t *testing.T) {
|
||||
r, _, sessions := newTestESM(t, &esmQueue{}, &esmInvokes{})
|
||||
rec, _, _ := esmMeta{}.Get(context.Background(), "123456789012", "ap-southeast-2", "f")
|
||||
sessions.life = esmRenewBefore + time.Minute
|
||||
s, err := r.fresh(context.Background(), rec, nil, esmRenewBefore)
|
||||
require.NoError(t, err)
|
||||
same, err := r.fresh(context.Background(), rec, s, esmRenewBefore)
|
||||
require.NoError(t, err)
|
||||
require.Same(t, s, same, "a session far from expiry is kept")
|
||||
|
||||
r.now = func() time.Time { return time.Now().Add(2 * time.Minute) }
|
||||
next, err := r.fresh(context.Background(), rec, s, esmRenewBefore)
|
||||
require.NoError(t, err)
|
||||
require.NotSame(t, s, next)
|
||||
require.Equal(t, []string{s.keyID}, sessions.revoked, "the replaced session is revoked")
|
||||
|
||||
rec.Role = "arn:aws:iam::123456789012:role/other"
|
||||
r.now = time.Now
|
||||
third, err := r.fresh(context.Background(), rec, next, esmRenewBefore)
|
||||
require.NoError(t, err)
|
||||
require.NotSame(t, next, third, "a changed execution role gets a new session")
|
||||
}
|
||||
|
||||
// Time passes during each invocation; the second event of one receive must
|
||||
// run and be acknowledged on a renewed session, not the one about to expire.
|
||||
func TestESM_EachSplitEventRunsOnACoveringSession(t *testing.T) {
|
||||
q := &esmQueue{}
|
||||
var mu sync.Mutex
|
||||
now := time.Now()
|
||||
inv := &esmInvokes{result: awsapi.SyncResult{ExecutedVersion: "$LATEST"}, during: func() {
|
||||
mu.Lock()
|
||||
now = now.Add(29 * time.Minute)
|
||||
mu.Unlock()
|
||||
}}
|
||||
r, _, sessions := newTestESM(t, q, inv)
|
||||
r.now = func() time.Time { mu.Lock(); defer mu.Unlock(); return now }
|
||||
sessions.life = 30 * time.Minute
|
||||
rec, _, _ := esmMeta{}.Get(context.Background(), "123456789012", "ap-southeast-2", "f")
|
||||
big := strings.Repeat("a", esmMaxEvent/2)
|
||||
var s *esmSession
|
||||
t.Cleanup(func() { r.revoke(s) })
|
||||
err := r.process(context.Background(), esmMapping("u1"), r.eventClient(context.Background(), rec, &s), "url",
|
||||
[]*sqs.Message{msg("1", big), msg("2", big)})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, inv.count(), "the two messages cannot share one event")
|
||||
require.Equal(t, 2, sessions.issued, "the second event renews the session")
|
||||
require.Equal(t, []string{"ASIAK"}, sessions.revoked)
|
||||
deleted, _ := q.state()
|
||||
require.Len(t, deleted, 2)
|
||||
}
|
||||
|
||||
// A failed invocation makes the worker back off before it receives again.
|
||||
func TestESM_InvocationFailureBacksOff(t *testing.T) {
|
||||
q := &esmQueue{pending: []*sqs.Message{msg("1", "hello")}, redeliver: true}
|
||||
inv := &esmInvokes{err: errors.New("TooManyRequestsException: busy")}
|
||||
r, store, _ := newTestESM(t, q, inv)
|
||||
r.backoff = 300 * time.Millisecond
|
||||
require.NoError(t, store.Create(context.Background(), esmMapping("u1")))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); r.run(ctx) }()
|
||||
time.Sleep(time.Second)
|
||||
cancel()
|
||||
<-done
|
||||
_, receives := q.state()
|
||||
require.LessOrEqual(t, receives, 4, "a failing batch is retried after a back-off, not in a tight loop")
|
||||
deleted, _ := q.state()
|
||||
require.Empty(t, deleted)
|
||||
}
|
||||
|
||||
func TestESM_ReceiveFailureRecordedAndCleared(t *testing.T) {
|
||||
q := &esmQueue{receiveErr: errors.New("AWS.SimpleQueueService.NonExistentQueue: The specified queue does not exist.")}
|
||||
r, store, _ := newTestESM(t, q, &esmInvokes{})
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
require.NoError(t, store.Create(ctx, esmMapping("u3")))
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); r.work(ctx, esmMapping("u3")) }()
|
||||
require.Eventually(t, func() bool {
|
||||
m, _, err := store.Get(ctx, "u3")
|
||||
return err == nil && strings.HasPrefix(m.StateTransitionReason, "PROBLEM: ") &&
|
||||
strings.Contains(m.StateTransitionReason, "NonExistentQueue")
|
||||
}, 5*time.Second, 10*time.Millisecond)
|
||||
m, _, err := store.Get(ctx, "u3")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, eventsource.StateEnabled, m.State, "a receive failure keeps the mapping")
|
||||
|
||||
q.mu.Lock()
|
||||
q.receiveErr = nil
|
||||
q.mu.Unlock()
|
||||
require.Eventually(t, func() bool {
|
||||
m, _, err := store.Get(ctx, "u3")
|
||||
return err == nil && m.StateTransitionReason == eventsource.ReasonUserInitiated
|
||||
}, 5*time.Second, 10*time.Millisecond)
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestESM_HandlerTransportCarriesSignedRequest(t *testing.T) {
|
||||
var got *http.Request
|
||||
var body string
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = r
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
body = string(b)
|
||||
w.Header().Set("Content-Type", "application/x-amz-json-1.0")
|
||||
_, _ = w.Write([]byte(`{"Attributes":{"VisibilityTimeout":"45"}}`))
|
||||
})
|
||||
r, _, _ := newTestESM(t, &esmQueue{}, &esmInvokes{})
|
||||
r.handler = h
|
||||
q := r.handlerQueue(credentials.NewStaticCredentials("ASIAX", "secret", "token"))
|
||||
out, err := q.GetQueueAttributes(&sqs.GetQueueAttributesInput{QueueUrl: aws.String("https://gw.example:9999/123456789012/in"),
|
||||
AttributeNames: aws.StringSlice([]string{"VisibilityTimeout"})})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "45", aws.StringValue(out.Attributes["VisibilityTimeout"]))
|
||||
require.Contains(t, got.Header.Get("Authorization"), "Credential=ASIAX/")
|
||||
require.Contains(t, got.Header.Get("Authorization"), "/ap-southeast-2/sqs/aws4_request")
|
||||
require.Equal(t, "token", got.Header.Get("X-Amz-Security-Token"))
|
||||
require.Equal(t, "gw.example:9999", got.Host)
|
||||
require.Contains(t, body, `"QueueUrl":"https://gw.example:9999/123456789012/in"`)
|
||||
keys := policyauth.RequestConditionKeys(got, credential.Principal{})
|
||||
require.Equal(t, "true", keys[iampolicy.KeySecureTransport], "a role requiring TLS must still authorize the poller")
|
||||
}
|
||||
|
||||
// activeMeta serves the invocation record as an Active function with code.
|
||||
type activeMeta struct{ function.Client }
|
||||
|
||||
func (activeMeta) Get(_ context.Context, _, _, _ string) (function.Record, uint64, error) {
|
||||
rec := invocation(nil).Function
|
||||
rec.State = function.StateActive
|
||||
rec.Code.State = function.CodeStored
|
||||
return rec, 1, nil
|
||||
}
|
||||
|
||||
// noVersionExec returns a result with no ExecutedVersion, which Invoke rejects.
|
||||
type noVersionExec struct{}
|
||||
|
||||
func (noVersionExec) InvokeSync(context.Context, awsapi.SyncInvocation) (awsapi.SyncResult, error) {
|
||||
return awsapi.SyncResult{Payload: []byte(`{}`)}, nil
|
||||
}
|
||||
|
||||
// Both batches run through Deps.InvokeEventSource: an invalid executor result
|
||||
// and a real executor refused by a node that already holds an attempt.
|
||||
func TestESM_InvokeEventSourceFailuresDeleteNothing(t *testing.T) {
|
||||
store := grantStore(t)
|
||||
held := &fakeControl{max: 1 << 20, err: errors.New("no reply")}
|
||||
_, err := executor(held, nil, store).InvokeSync(context.Background(), invocation(nil))
|
||||
require.Error(t, err)
|
||||
busy := &fakeControl{max: 1 << 20, reply: &testlink.Reply{Outcome: testlink.OutcomeDone}}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
exec awsapi.Executor
|
||||
}{
|
||||
{name: "missing ExecutedVersion", exec: noVersionExec{}},
|
||||
{name: "node busy", exec: executor(busy, nil, store)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
q := &esmQueue{}
|
||||
r, _, _ := newTestESM(t, q, nil)
|
||||
r.invoke = awsapi.Deps{Meta: activeMeta{}, Exec: tc.exec}
|
||||
err := r.process(context.Background(), esmMapping("u1"), func() (sqsiface.SQSAPI, error) { return q, nil }, "url",
|
||||
[]*sqs.Message{msg("1", "a")})
|
||||
require.Error(t, err)
|
||||
deleted, _ := q.state()
|
||||
require.Empty(t, deleted)
|
||||
})
|
||||
}
|
||||
require.Empty(t, busy.sent, "the busy node is sent nothing")
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{Ct as e,Et as t,Tt as n}from"./schemas-DCQXG11d.js";import{r}from"./CompositeRoot-CmHoR-Tk.js";function i(i){let{render:a,className:o,style:s,state:c=t,props:l=n,refs:u=n,metadata:d,stateAttributesMapping:f,tag:p=`div`,...m}=i,{compositeProps:h,compositeRef:g}=r({metadata:d});return e(p,i,{state:c,ref:[g,...u],props:[h,...l,m],stateAttributesMapping:f})}export{i as t};
|
||||
import{Ct as e,Et as t,Tt as n}from"./schemas-CjNLFp_1.js";import{r}from"./CompositeRoot-inp2JFFI.js";function i(i){let{render:a,className:o,style:s,state:c=t,props:l=n,refs:u=n,metadata:d,stateAttributesMapping:f,tag:p=`div`,...m}=i,{compositeProps:h,compositeRef:g}=r({metadata:d});return e(p,i,{state:c,ref:[g,...u],props:[h,...l,m],stateAttributesMapping:f})}export{i as t};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Ct as t,Et as n,It as r,Lt as i,Q as a,Tt as o,Z as s,et as c,kt as l,tt as u}from"./schemas-DCQXG11d.js";import{$ as d,_ as f,a as p,d as m,i as h,l as g,m as _,n as v,p as y,r as b,s as x,t as S,u as C,v as w}from"./composite-DysENEse.js";import{i as T,n as E,r as D,t as O}from"./isElementDisabled-B7Ri5ug6.js";var k=e(i(),1);function A(e={}){let{highlightItemOnHover:t,highlightedIndex:n,onHighlightedIndexChange:r}=a(),{ref:i,index:o}=E(e),s=n===o,c=k.useRef(null),u=l(i,c);return{compositeProps:{tabIndex:s?0:-1,onFocus(){r(o)},onMouseMove(){let e=c.current;if(!t||!e)return;let n=e.hasAttribute(`disabled`)||e.ariaDisabled===`true`;!s&&!n&&e.focus()}},compositeRef:u,index:o}}var j=`data-composite-item-active`;function M(e){let{loopFocus:t=!0,orientation:n=`both`,grid:r,onLoop:i,direction:a,highlightedIndex:s,onHighlightedIndexChange:x,rootRef:T,enableHomeAndEndKeys:E=!1,stopEventPropagation:D,disabledIndices:A,modifierKeys:j=o}=e,[M,F]=k.useState(0),I=r!=null,L=k.useRef(null),R=l(L,T),z=k.useRef([]),B=k.useRef(!1),V=k.useRef(null),H=s??M,U=u((e,t=!1)=>{if(V.current=z.current[e]??null,(x??F)(e),t){let t=z.current[e];C(L.current,t,a,n)}}),W=u(e=>{if(e.size===0)return;if(B.current){let e=z.current,t=e.indexOf(V.current);if(t===-1){let t=e[H];!t||w(e,H,A)?U(N(e,A)):V.current=t}else t!==H&&U(t);return}B.current=!0;let t=Array.from(e.keys()),r=t.find(e=>e?.hasAttribute(`data-composite-item-active`))??null,i=r?e.get(r)?.index??-1:-1;if(i!==-1)U(i);else if(w(t,H,A)){let e=m(t,{disabledIndices:A});f(t,e)||U(e)}C(L.current,r,a,n)});c(()=>{if(A==null||s!=null||!B.current)return;let e=z.current;if(w(e,H,A)){let t=m(e,{disabledIndices:A});f(e,t)||U(t)}},[A,s,H,z,U]);let G=u((e,t,n)=>i?i(e,t,n,z):n),K=u(e=>{let o=e.key===`Home`||e.key===`End`;if(!p.has(e.key)||!E&&o||P(e,j)||!L.current)return;let s=a===`rtl`,c=s?v:b,l=s?b:v,u=n===`vertical`?S:c,x=n===`vertical`?h:l,C=d(e.nativeEvent);if(C!=null&&g(C)&&!O(C)){let t=C.selectionStart,n=C.selectionEnd,r=C.value;if(t==null||e.shiftKey||t!==n||e.key!==x&&t<r.length||e.key!==u&&t>0)return}let w=H,T=_(z,A),k=y(z,A);r!=null&&(w=r({disabledIndices:A,elementsRef:z,event:e,highlightedIndex:H,loopFocus:t,maxIndex:k,minIndex:T,onLoop:G,orientation:n,rtl:s}));let M=n!==`vertical`&&e.key===c||n!==`horizontal`&&e.key===`ArrowDown`,N=n!==`vertical`&&e.key===l||n!==`horizontal`&&e.key===`ArrowUp`;E&&(e.key===`Home`?w=T:e.key===`End`&&(w=k)),w===H&&(M||N)&&(t&&w===k&&M?(w=T,i&&(w=i(e,H,w,z))):t&&w===T&&N?(w=k,i&&(w=i(e,H,w,z))):w=m(z.current,{startingIndex:w,decrement:N,disabledIndices:A})),w!==H&&!f(z.current,w)&&(D&&e.stopPropagation(),(I||o||M||N)&&e.preventDefault(),U(w,!0),queueMicrotask(()=>{z.current[w]?.focus()}))});return{props:{ref:R,onFocus(e){let t=L.current,n=d(e.nativeEvent);t&&n!=null&&g(n)&&n.setSelectionRange(0,n.value.length)},onKeyDown:K},highlightedIndex:H,onHighlightedIndexChange:U,elementsRef:z,onMapChange:W,relayKeyboardEvent:K}}function N(e,t){let n=-1;for(let r=0;r<e.length;r+=1){let i=e[r];if(i&&!w(e,r,t)){if(i.hasAttribute(`data-composite-item-active`))return r;n===-1&&(n=r)}}return Math.max(n,0)}function P(e,t){for(let n of x)if(!t.includes(n)&&e.getModifierState(n))return!0;return!1}var F=r();function I(e){let{render:r,className:i,style:a,refs:c=o,props:l=o,state:u=n,stateAttributesMapping:d,highlightedIndex:f,onHighlightedIndexChange:p,orientation:m,grid:h,loopFocus:g,onLoop:_,enableHomeAndEndKeys:v,onMapChange:y,stopEventPropagation:b=!0,rootRef:x,disabledIndices:S,modifierKeys:C,highlightItemOnHover:w=!1,tag:E=`div`,...O}=e,{props:A,highlightedIndex:j,onHighlightedIndexChange:N,elementsRef:P,onMapChange:I,relayKeyboardEvent:L}=M({grid:h,loopFocus:g,onLoop:_,orientation:m,highlightedIndex:f,onHighlightedIndexChange:p,rootRef:x,stopEventPropagation:b,enableHomeAndEndKeys:v,direction:T(),disabledIndices:S,modifierKeys:C}),R=t(E,e,{state:u,ref:c,props:[A,...l,O],stateAttributesMapping:d}),z=k.useMemo(()=>({highlightedIndex:j,onHighlightedIndexChange:N,highlightItemOnHover:w,relayKeyboardEvent:L}),[j,N,w,L]);return(0,F.jsx)(s.Provider,{value:z,children:(0,F.jsx)(D,{elementsRef:P,onMapChange:e=>{y?.(e),I(e)},children:R})})}export{j as n,A as r,I as t};
|
||||
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{Ct as t,Et as n,It as r,Lt as i,Q as a,Tt as o,Z as s,et as c,kt as l,tt as u}from"./schemas-CjNLFp_1.js";import{$ as d,_ as f,a as p,d as m,i as h,l as g,m as _,n as v,p as y,r as b,s as x,t as S,u as C,v as w}from"./composite-BSJwpiyu.js";import{i as T,n as E,r as D,t as O}from"./isElementDisabled-4f1svCb6.js";var k=e(i(),1);function A(e={}){let{highlightItemOnHover:t,highlightedIndex:n,onHighlightedIndexChange:r}=a(),{ref:i,index:o}=E(e),s=n===o,c=k.useRef(null),u=l(i,c);return{compositeProps:{tabIndex:s?0:-1,onFocus(){r(o)},onMouseMove(){let e=c.current;if(!t||!e)return;let n=e.hasAttribute(`disabled`)||e.ariaDisabled===`true`;!s&&!n&&e.focus()}},compositeRef:u,index:o}}var j=`data-composite-item-active`;function M(e){let{loopFocus:t=!0,orientation:n=`both`,grid:r,onLoop:i,direction:a,highlightedIndex:s,onHighlightedIndexChange:x,rootRef:T,enableHomeAndEndKeys:E=!1,stopEventPropagation:D,disabledIndices:A,modifierKeys:j=o}=e,[M,F]=k.useState(0),I=r!=null,L=k.useRef(null),R=l(L,T),z=k.useRef([]),B=k.useRef(!1),V=k.useRef(null),H=s??M,U=u((e,t=!1)=>{if(V.current=z.current[e]??null,(x??F)(e),t){let t=z.current[e];C(L.current,t,a,n)}}),W=u(e=>{if(e.size===0)return;if(B.current){let e=z.current,t=e.indexOf(V.current);if(t===-1){let t=e[H];!t||w(e,H,A)?U(N(e,A)):V.current=t}else t!==H&&U(t);return}B.current=!0;let t=Array.from(e.keys()),r=t.find(e=>e?.hasAttribute(`data-composite-item-active`))??null,i=r?e.get(r)?.index??-1:-1;if(i!==-1)U(i);else if(w(t,H,A)){let e=m(t,{disabledIndices:A});f(t,e)||U(e)}C(L.current,r,a,n)});c(()=>{if(A==null||s!=null||!B.current)return;let e=z.current;if(w(e,H,A)){let t=m(e,{disabledIndices:A});f(e,t)||U(t)}},[A,s,H,z,U]);let G=u((e,t,n)=>i?i(e,t,n,z):n),K=u(e=>{let o=e.key===`Home`||e.key===`End`;if(!p.has(e.key)||!E&&o||P(e,j)||!L.current)return;let s=a===`rtl`,c=s?v:b,l=s?b:v,u=n===`vertical`?S:c,x=n===`vertical`?h:l,C=d(e.nativeEvent);if(C!=null&&g(C)&&!O(C)){let t=C.selectionStart,n=C.selectionEnd,r=C.value;if(t==null||e.shiftKey||t!==n||e.key!==x&&t<r.length||e.key!==u&&t>0)return}let w=H,T=_(z,A),k=y(z,A);r!=null&&(w=r({disabledIndices:A,elementsRef:z,event:e,highlightedIndex:H,loopFocus:t,maxIndex:k,minIndex:T,onLoop:G,orientation:n,rtl:s}));let M=n!==`vertical`&&e.key===c||n!==`horizontal`&&e.key===`ArrowDown`,N=n!==`vertical`&&e.key===l||n!==`horizontal`&&e.key===`ArrowUp`;E&&(e.key===`Home`?w=T:e.key===`End`&&(w=k)),w===H&&(M||N)&&(t&&w===k&&M?(w=T,i&&(w=i(e,H,w,z))):t&&w===T&&N?(w=k,i&&(w=i(e,H,w,z))):w=m(z.current,{startingIndex:w,decrement:N,disabledIndices:A})),w!==H&&!f(z.current,w)&&(D&&e.stopPropagation(),(I||o||M||N)&&e.preventDefault(),U(w,!0),queueMicrotask(()=>{z.current[w]?.focus()}))});return{props:{ref:R,onFocus(e){let t=L.current,n=d(e.nativeEvent);t&&n!=null&&g(n)&&n.setSelectionRange(0,n.value.length)},onKeyDown:K},highlightedIndex:H,onHighlightedIndexChange:U,elementsRef:z,onMapChange:W,relayKeyboardEvent:K}}function N(e,t){let n=-1;for(let r=0;r<e.length;r+=1){let i=e[r];if(i&&!w(e,r,t)){if(i.hasAttribute(`data-composite-item-active`))return r;n===-1&&(n=r)}}return Math.max(n,0)}function P(e,t){for(let n of x)if(!t.includes(n)&&e.getModifierState(n))return!0;return!1}var F=r();function I(e){let{render:r,className:i,style:a,refs:c=o,props:l=o,state:u=n,stateAttributesMapping:d,highlightedIndex:f,onHighlightedIndexChange:p,orientation:m,grid:h,loopFocus:g,onLoop:_,enableHomeAndEndKeys:v,onMapChange:y,stopEventPropagation:b=!0,rootRef:x,disabledIndices:S,modifierKeys:C,highlightItemOnHover:w=!1,tag:E=`div`,...O}=e,{props:A,highlightedIndex:j,onHighlightedIndexChange:N,elementsRef:P,onMapChange:I,relayKeyboardEvent:L}=M({grid:h,loopFocus:g,onLoop:_,orientation:m,highlightedIndex:f,onHighlightedIndexChange:p,rootRef:x,stopEventPropagation:b,enableHomeAndEndKeys:v,direction:T(),disabledIndices:S,modifierKeys:C}),R=t(E,e,{state:u,ref:c,props:[A,...l,O],stateAttributesMapping:d}),z=k.useMemo(()=>({highlightedIndex:j,onHighlightedIndexChange:N,highlightItemOnHover:w,relayKeyboardEvent:L}),[j,N,w,L]);return(0,F.jsx)(s.Provider,{value:z,children:(0,F.jsx)(D,{elementsRef:P,onMapChange:e=>{y?.(e),I(e)},children:R})})}export{j as n,A as r,I as t};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
import{F as e}from"./aws-client-CqvC9t1w.js";import{n as t,r as n,t as r}from"./commandBuilder-BH8PZikB.js";var i=class extends n(r,t,`DescribeDBInstances`,e){};export{i as t};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
import{gn as e}from"./aws-client-CqvC9t1w.js";import{c as t,l as n,s as r}from"./tags-D0OFtoei.js";var i=class extends n(r,t,`DescribeLoadBalancers`,e){};export{i as t};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{It as t,Lt as n}from"./schemas-DCQXG11d.js";import{d as r,r as i}from"./matchContext-CUAcfcYw.js";import{n as a}from"./x-mlA6pp-8.js";import{t as o}from"./useRouter-B_eSHXWd.js";import{t as s}from"./useSelector-CDb-n1FW.js";var c=e(n(),1),l=t(),u=()=>!0,d=()=>!1;function f({children:e,fallback:t=null}){return(0,l.jsx)(c.Fragment,{children:p()?e:t})}function p(e=!0){return c.useSyncExternalStore(m,u,e?d:u)}function m(){return()=>{}}function h(e){let t=o(),n=`not-found-${s(t.stores.location,e=>e.pathname)}-${s(t.stores.status)}`;return(0,l.jsx)(a,{getResetKey:()=>n,onCatch:(t,n)=>{if(r(t))e.onCatch?.(t,n);else throw t},errorComponent:({error:t})=>{if(r(t))return e.fallback?.(t);throw t},children:e.children})}function g(){return(0,l.jsx)(`p`,{children:`Not Found`})}function _(e,t,n){return t.options.notFoundComponent?(0,l.jsx)(t.options.notFoundComponent,{...n}):e.options.defaultNotFoundComponent?(0,l.jsx)(e.options.defaultNotFoundComponent,{...n}):(0,l.jsx)(g,{})}function v(e,t){let n=t?.options.pendingComponent??e.options.defaultPendingComponent;return n?(0,l.jsx)(n,{}):null}var y=(e,t)=>e[0]===t[0]&&e[1]===t[1],b=(e,t,n)=>!t.isRoot||t.options.shellComponent||t.options.wrapInSuspense||n===!1||n===`data-only`||!e.ssr,x=c.memo(function({routeId:e}){let t=o(),n=t.stores.getMatchStore(e);return S(t,s(n))});function S(e,t){let n=e.routesById[t.routeId],o=v(e,n),s=n.options.errorComponent??e.options.defaultErrorComponent,u=n.options.onCatch??e.options.defaultOnCatch,d=n.isRoot?n.options.notFoundComponent??e.options.notFoundRoute?.options.component:n.options.notFoundComponent,p=t.ssr===!1||t.ssr===`data-only`,m=b(e,n,t.ssr)&&(n.options.wrapInSuspense??o??(n.options.errorComponent?.preload||p)),g=(0,l.jsx)(C,{match:t});p&&(g=(0,l.jsx)(f,{fallback:o,children:g})),d&&(g=(0,l.jsx)(h,{fallback:e=>{if(e.routeId??=t.routeId,e.routeId!==t.routeId)throw e;return c.createElement(d,e)},children:g})),s&&(g=(0,l.jsx)(a,{getResetKey:()=>t,errorComponent:s,onCatch:(e,n)=>{if(r(e))throw e.routeId??=t.routeId,e;u?.(e,n)},children:g})),m&&(g=(0,l.jsx)(c.Suspense,{fallback:o,children:g}));let _=n.isRoot&&n.options.shellComponent;return _?(0,l.jsx)(i.Provider,{value:t.routeId,children:(0,l.jsxs)(_,{children:[g,null]})}):(0,l.jsxs)(i.Provider,{value:t.routeId,children:[g,null]})}var C=c.memo(function({match:e}){let t=o(),n=e.routeId,r=t.routesById[n],i=c.useMemo(()=>{let i=(r.options.remountDeps??t.options.defaultRemountDeps)?.({routeId:n,loaderDeps:e.loaderDeps,params:e._strictParams,search:e._strictSearch});return i?JSON.stringify(i):void 0},[n,e.loaderDeps,e._strictParams,e._strictSearch,r.options.remountDeps,t.options.defaultRemountDeps]),a=c.useMemo(()=>{let e=r.options.component??t.options.defaultComponent;return e?(0,l.jsx)(e,{},i):(0,l.jsx)(w,{})},[i,r.options.component,t.options.defaultComponent]);if(e.status===`pending`){if(t.ssr&&!b(t,r,e.ssr))return a;if(t._tx)throw t._tx[5];return v(t,r)}if(e.status===`notFound`)return _(t,r,e.error);if(e.status===`error`)throw e.error;return a}),w=c.memo(function(){let e=o(),t=c.useContext(i),n,r,a;{let i=e.stores.getMatchStore(t);[n,r]=s(i,e=>[!!e._notFound,e.error],{compare:y}),a=s(e.stores.ids,e=>e[e.indexOf(t)+1])}if(n)return _(e,e.routesById[t],r);if(!a)return null;let u=(0,l.jsx)(x,{routeId:a});return t===`__root__`?(0,l.jsx)(c.Suspense,{fallback:v(e),children:u}):u});export{p as i,w as n,v as r,x as t};
|
||||
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{It as t,Lt as n}from"./schemas-CjNLFp_1.js";import{d as r,r as i}from"./matchContext-BrTj2Lqx.js";import{n as a}from"./x-BT7BjjO9.js";import{t as o}from"./useRouter-BH2du3mx.js";import{t as s}from"./useSelector-C5gpvoOl.js";var c=e(n(),1),l=t(),u=()=>!0,d=()=>!1;function f({children:e,fallback:t=null}){return(0,l.jsx)(c.Fragment,{children:p()?e:t})}function p(e=!0){return c.useSyncExternalStore(m,u,e?d:u)}function m(){return()=>{}}function h(e){let t=o(),n=`not-found-${s(t.stores.location,e=>e.pathname)}-${s(t.stores.status)}`;return(0,l.jsx)(a,{getResetKey:()=>n,onCatch:(t,n)=>{if(r(t))e.onCatch?.(t,n);else throw t},errorComponent:({error:t})=>{if(r(t))return e.fallback?.(t);throw t},children:e.children})}function g(){return(0,l.jsx)(`p`,{children:`Not Found`})}function _(e,t,n){return t.options.notFoundComponent?(0,l.jsx)(t.options.notFoundComponent,{...n}):e.options.defaultNotFoundComponent?(0,l.jsx)(e.options.defaultNotFoundComponent,{...n}):(0,l.jsx)(g,{})}function v(e,t){let n=t?.options.pendingComponent??e.options.defaultPendingComponent;return n?(0,l.jsx)(n,{}):null}var y=(e,t)=>e[0]===t[0]&&e[1]===t[1],b=(e,t,n)=>!t.isRoot||t.options.shellComponent||t.options.wrapInSuspense||n===!1||n===`data-only`||!e.ssr,x=c.memo(function({routeId:e}){let t=o(),n=t.stores.getMatchStore(e);return S(t,s(n))});function S(e,t){let n=e.routesById[t.routeId],o=v(e,n),s=n.options.errorComponent??e.options.defaultErrorComponent,u=n.options.onCatch??e.options.defaultOnCatch,d=n.isRoot?n.options.notFoundComponent??e.options.notFoundRoute?.options.component:n.options.notFoundComponent,p=t.ssr===!1||t.ssr===`data-only`,m=b(e,n,t.ssr)&&(n.options.wrapInSuspense??o??(n.options.errorComponent?.preload||p)),g=(0,l.jsx)(C,{match:t});p&&(g=(0,l.jsx)(f,{fallback:o,children:g})),d&&(g=(0,l.jsx)(h,{fallback:e=>{if(e.routeId??=t.routeId,e.routeId!==t.routeId)throw e;return c.createElement(d,e)},children:g})),s&&(g=(0,l.jsx)(a,{getResetKey:()=>t,errorComponent:s,onCatch:(e,n)=>{if(r(e))throw e.routeId??=t.routeId,e;u?.(e,n)},children:g})),m&&(g=(0,l.jsx)(c.Suspense,{fallback:o,children:g}));let _=n.isRoot&&n.options.shellComponent;return _?(0,l.jsx)(i.Provider,{value:t.routeId,children:(0,l.jsxs)(_,{children:[g,null]})}):(0,l.jsxs)(i.Provider,{value:t.routeId,children:[g,null]})}var C=c.memo(function({match:e}){let t=o(),n=e.routeId,r=t.routesById[n],i=c.useMemo(()=>{let i=(r.options.remountDeps??t.options.defaultRemountDeps)?.({routeId:n,loaderDeps:e.loaderDeps,params:e._strictParams,search:e._strictSearch});return i?JSON.stringify(i):void 0},[n,e.loaderDeps,e._strictParams,e._strictSearch,r.options.remountDeps,t.options.defaultRemountDeps]),a=c.useMemo(()=>{let e=r.options.component??t.options.defaultComponent;return e?(0,l.jsx)(e,{},i):(0,l.jsx)(w,{})},[i,r.options.component,t.options.defaultComponent]);if(e.status===`pending`){if(t.ssr&&!b(t,r,e.ssr))return a;if(t._tx)throw t._tx[5];return v(t,r)}if(e.status===`notFound`)return _(t,r,e.error);if(e.status===`error`)throw e.error;return a}),w=c.memo(function(){let e=o(),t=c.useContext(i),n,r,a;{let i=e.stores.getMatchStore(t);[n,r]=s(i,e=>[!!e._notFound,e.error],{compare:y}),a=s(e.stores.ids,e=>e[e.indexOf(t)+1])}if(n)return _(e,e.routesById[t],r);if(!a)return null;let u=(0,l.jsx)(x,{routeId:a});return t===`__root__`?(0,l.jsx)(c.Suspense,{fallback:v(e),children:u}):u});export{p as i,w as n,v as r,x as t};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{It as t,Lt as n}from"./schemas-DCQXG11d.js";var r=e(n(),1),i=t(),a=r.createContext(void 0),o=e=>{let t=r.useContext(a);if(e)return e;if(!t)throw Error(`No QueryClient set, use QueryClientProvider to set one`);return t},s=({client:e,children:t})=>(r.useEffect(()=>(e.mount(),()=>{e.unmount()}),[e]),(0,i.jsx)(a.Provider,{value:e,children:t}));export{o as n,s as t};
|
||||
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{It as t,Lt as n}from"./schemas-CjNLFp_1.js";var r=e(n(),1),i=t(),a=r.createContext(void 0),o=e=>{let t=r.useContext(a);if(e)return e;if(!t)throw Error(`No QueryClient set, use QueryClientProvider to set one`);return t},s=({client:e,children:t})=>(r.useEffect(()=>(e.mount(),()=>{e.unmount()}),[e]),(0,i.jsx)(a.Provider,{value:e,children:t}));export{o as n,s as t};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{$ as t,Lt as n}from"./schemas-DCQXG11d.js";var r=e(n(),1),i=r.createContext(void 0);function a(e){let n=r.useContext(i);if(n===void 0&&!e)throw Error(t(69));return n}export{a as t};
|
||||
import{r as e}from"./rolldown-runtime-XKyaHtl9.js";import{$ as t,Lt as n}from"./schemas-CjNLFp_1.js";var r=e(n(),1),i=r.createContext(void 0);function a(e){let n=r.useContext(i);if(n===void 0&&!e)throw Error(t(69));return n}export{a as t};
|
||||
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_-Cd8wKnd1.js","assets/schemas-CjNLFp_1.js","assets/rolldown-runtime-XKyaHtl9.js","assets/object-browser-C28HziMZ.js","assets/QueryClientProvider-CiUnq0HS.js","assets/queryOptions-BF_U72jK.js","assets/useQuery-BcsGTOcM.js","assets/link-BkUlBzrf.js","assets/matchContext-BrTj2Lqx.js","assets/useRouter-BH2du3mx.js","assets/data-table-CPb-LbjI.js","assets/useSelector-C5gpvoOl.js","assets/createLucideIcon-BexmpBS_.js","assets/check-mxX6T63H.js","assets/useRegisterFieldControl-ByyVMl5k.js","assets/useAriaLabelledBy-BTi8eo3U.js","assets/useTransitionStatus-Cyq0ANhR.js","assets/useValueChanged-Cq_W0zj9.js","assets/visuallyHidden-CmQ9ItcI.js","assets/useControlled-K3TUf9-y.js","assets/table-CkfpcZOx.js","assets/useNavigate-C8Cxi52b.js","assets/chevron-right-D3YEkqSd.js","assets/upload-DOoBJMnN.js","assets/eye-7qRQPg_Z.js","assets/input-group-DCN87RY_.js","assets/useValueAsRef-DY5n0BXa.js","assets/field-D7Rhpj9A.js","assets/textarea-BNn1cdVG.js","assets/sliders-horizontal-BfmfK97o.js","assets/trash-v7j_Lmza.js","assets/detail-panel-9quwJD4r.js","assets/x-BT7BjjO9.js","assets/info-tip-BlsL6iHw.js","assets/useScrollLock-ylk6FkWX.js","assets/composite-BSJwpiyu.js","assets/useAnchoredPopupScrollLock-D6ZKkcog.js","assets/isElementDisabled-4f1svCb6.js","assets/store-ud_TzzLS.js","assets/ToolbarRootContext-Dx0lufmd.js","assets/chevron-down-CMnru2aW.js","assets/copy-DAui9L5b.js","assets/loader-circle-Daekigmu.js","assets/collapsible-CSbzTZF0.js","assets/tabs-D3qJM6Sn.js","assets/CompositeRoot-inp2JFFI.js","assets/panel-search-BFfs0Klq.js","assets/error-banner-CoEZZQBV.js","assets/use-copy-to-clipboard-BgG7vE9f.js","assets/uploads-BKZ7RYON.js","assets/aws-client-CqvC9t1w.js","assets/auth-D8BwZ3UA.js","assets/fetch-http-handler-6B81XedT.js","assets/toUtf8.browser-RsrLknAJ.js","assets/index.browser-D7GVEViY.js","assets/s3-DIc9oJl8.js","assets/useMutation-CExmKqTG.js","assets/s3-q6zqUef5.js","assets/page-heading-BOhZ5HUE.js","assets/resource-view-p1jimfyt.js","assets/selection-bar-D2Ha-b1w.js","assets/use-row-selection-BZFNKBBc.js","assets/bulk-delete-BnNFtolc.js","assets/circle-x-BxOJ9B1g.js","assets/alert-dialog-B-pr568k.js","assets/DialogTitle-BqK2_8dh.js","assets/confirm-phrase-CsFRFZdW.js","assets/resource-delete-dialog-CdO3aw07.js","assets/delete-confirmation-dialog-Dx0nBQ7s.js"])))=>i.map(i=>d[i]);
|
||||
import{B as e,s as t}from"./schemas-CjNLFp_1.js";import{n,t as r}from"./lazyRouteComponent-ZWgeB3Vm.js";import{t as i}from"./searchMiddleware-ixtO4F_Y.js";import{U as a}from"./fetch-http-handler-6B81XedT.js";import{t as o}from"./s3-q6zqUef5.js";import{r as s,t as c}from"./panel-search-BFfs0Klq.js";var l=n(`/_auth/s3/ls/$bucket/$`)({validateSearch:t(s),search:{middlewares:[i(c)]},loader:async({context:t,params:n})=>{await t.queryClient.query({...o(n.bucket,e(n._splat??``)),staleTime:`static`})},head:({params:t})=>({meta:[{title:`${t.bucket}/${e(t._splat??``)} | Object Storage | Mulga`}]}),component:r(()=>a(()=>import(`./_-Cd8wKnd1.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68])),`component`)});export{l as t};
|
||||
@@ -1,2 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_-BpXObMlR.js","assets/schemas-DCQXG11d.js","assets/rolldown-runtime-XKyaHtl9.js","assets/object-browser-CgVAiY2i.js","assets/QueryClientProvider-BuG40HBe.js","assets/queryOptions-Cu2lvOFG.js","assets/useQuery-D6TojLgI.js","assets/link-BX-1BPor.js","assets/matchContext-CUAcfcYw.js","assets/useRouter-B_eSHXWd.js","assets/data-table-9n7xOy9s.js","assets/useSelector-CDb-n1FW.js","assets/visuallyHidden-BHQz5bmH.js","assets/check-DPoQ9cBn.js","assets/useRegisterFieldControl-DkI655of.js","assets/useAriaLabelledBy-DStQJVTn.js","assets/useTransitionStatus-Dn-09Anc.js","assets/useValueChanged-wii0yYWk.js","assets/useControlled-NIWPmeG5.js","assets/table-BLvCYTga.js","assets/useNavigate-cMdr_EJ0.js","assets/chevron-right-DZULbaNT.js","assets/upload-uiiRWdV_.js","assets/eye-yi4wz0yh.js","assets/input-group-TEGNx6dm.js","assets/useValueAsRef-uzS1fuI8.js","assets/field-iLG94M00.js","assets/textarea-BG1iGvVs.js","assets/sliders-horizontal-DsGbsnBU.js","assets/trash-BajuWzqn.js","assets/detail-panel-ukM1Xs6L.js","assets/x-mlA6pp-8.js","assets/info-tip-Cxa2GEOd.js","assets/useScrollLock-iGnmnC-h.js","assets/composite-DysENEse.js","assets/useAnchoredPopupScrollLock-CoHshdlQ.js","assets/isElementDisabled-B7Ri5ug6.js","assets/store-BVhNJayb.js","assets/ToolbarRootContext-iMMgkc2r.js","assets/chevron-down-BgudX6bq.js","assets/copy-B9ICxw_u.js","assets/loader-circle-C7EgoGp_.js","assets/collapsible-DddM174D.js","assets/tabs-9tHEWpNn.js","assets/CompositeRoot-CmHoR-Tk.js","assets/panel-search-WG3n4DSv.js","assets/error-banner-Cahg3Ix2.js","assets/use-copy-to-clipboard-D-PxomFK.js","assets/uploads-DWKB6oFv.js","assets/aws-client-DrLAG3yq.js","assets/auth-BS4WytAe.js","assets/fetch-http-handler-C0nfkUzI.js","assets/toUtf8.browser-RsrLknAJ.js","assets/index.browser-D7GVEViY.js","assets/s3-C1ngKIr4.js","assets/useMutation-lRi0DDcO.js","assets/s3-PnGphw-P.js","assets/page-heading-ZfrnK2wu.js","assets/resource-view-COfsV2nr.js","assets/selection-bar-WnknBhRB.js","assets/use-row-selection-DB6L9NiD.js","assets/bulk-delete-CXIUB03J.js","assets/circle-x-C6c0uvb-.js","assets/alert-dialog-D9jG-rkO.js","assets/DialogTitle-nAIOTphJ.js","assets/confirm-phrase-C4Dux-0Y.js","assets/resource-delete-dialog-D3NYuO-k.js","assets/delete-confirmation-dialog-O7cqOW0Y.js"])))=>i.map(i=>d[i]);
|
||||
import{B as e,s as t}from"./schemas-DCQXG11d.js";import{n,t as r}from"./lazyRouteComponent-PMetaA3O.js";import{t as i}from"./searchMiddleware-Clk50hy1.js";import{U as a}from"./fetch-http-handler-C0nfkUzI.js";import{t as o}from"./s3-PnGphw-P.js";import{r as s,t as c}from"./panel-search-WG3n4DSv.js";var l=n(`/_auth/s3/ls/$bucket/$`)({validateSearch:t(s),search:{middlewares:[i(c)]},loader:async({context:t,params:n})=>{await t.queryClient.query({...o(n.bucket,e(n._splat??``)),staleTime:`static`})},head:({params:t})=>({meta:[{title:`${t.bucket}/${e(t._splat??``)} | Object Storage | Mulga`}]}),component:r(()=>a(()=>import(`./_-BpXObMlR.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67])),`component`)});export{l as t};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{B as e,It as t,Mt as n}from"./schemas-DCQXG11d.js";import{t as r}from"./object-browser-CgVAiY2i.js";import{t as i}from"./_-Brvv-Wk8.js";var a=n(),o=t();function s(){let t=(0,a.c)(10),{bucket:n,_splat:s}=i.useParams(),c=s??``,l;t[0]===c?l=t[1]:(l=e(c),t[0]=c,t[1]=l);let u=l,d=i.useSearch(),f=i.useNavigate(),p;t[2]===f?p=t[3]:(p=function(e){f({search:t=>({...t,...e}),replace:!(`selected`in e)})},t[2]=f,t[3]=p);let m=p,h=`${n}/${u}`,g;return t[4]!==n||t[5]!==u||t[6]!==m||t[7]!==d||t[8]!==h?(g=(0,o.jsx)(r,{bucket:n,folder:u,onSearchChange:m,search:d},h),t[4]=n,t[5]=u,t[6]=m,t[7]=d,t[8]=h,t[9]=g):g=t[9],g}export{s as component};
|
||||
import{B as e,It as t,Mt as n}from"./schemas-CjNLFp_1.js";import{t as r}from"./object-browser-C28HziMZ.js";import{t as i}from"./_-BHUnJry6.js";var a=n(),o=t();function s(){let t=(0,a.c)(10),{bucket:n,_splat:s}=i.useParams(),c=s??``,l;t[0]===c?l=t[1]:(l=e(c),t[0]=c,t[1]=l);let u=l,d=i.useSearch(),f=i.useNavigate(),p;t[2]===f?p=t[3]:(p=function(e){f({search:t=>({...t,...e}),replace:!(`selected`in e)})},t[2]=f,t[3]=p);let m=p,h=`${n}/${u}`,g;return t[4]!==n||t[5]!==u||t[6]!==m||t[7]!==d||t[8]!==h?(g=(0,o.jsx)(r,{bucket:n,folder:u,onSearchChange:m,search:d},h),t[4]=n,t[5]=u,t[6]=m,t[7]=d,t[8]=h,t[9]=g):g=t[9],g}export{s as component};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{It as e,Mt as t}from"./schemas-DCQXG11d.js";import{t as n}from"./object-browser-CgVAiY2i.js";import{t as r}from"./_bucket-C3T0RWGE.js";var i=t(),a=e();function o(){let e=(0,i.c)(6),{bucket:t}=r.useParams(),o=r.useSearch(),s=r.useNavigate(),c;e[0]===s?c=e[1]:(c=function(e){s({search:t=>({...t,...e}),replace:!(`selected`in e)})},e[0]=s,e[1]=c);let l=c,u;return e[2]!==t||e[3]!==l||e[4]!==o?(u=(0,a.jsx)(n,{bucket:t,folder:``,onSearchChange:l,search:o},t),e[2]=t,e[3]=l,e[4]=o,e[5]=u):u=e[5],u}export{o as component};
|
||||
import{It as e,Mt as t}from"./schemas-CjNLFp_1.js";import{t as n}from"./object-browser-C28HziMZ.js";import{t as r}from"./_bucket-DA4No3An.js";var i=t(),a=e();function o(){let e=(0,i.c)(6),{bucket:t}=r.useParams(),o=r.useSearch(),s=r.useNavigate(),c;e[0]===s?c=e[1]:(c=function(e){s({search:t=>({...t,...e}),replace:!(`selected`in e)})},e[0]=s,e[1]=c);let l=c,u;return e[2]!==t||e[3]!==l||e[4]!==o?(u=(0,a.jsx)(n,{bucket:t,folder:``,onSearchChange:l,search:o},t),e[2]=t,e[3]=l,e[4]=o,e[5]=u):u=e[5],u}export{o as component};
|
||||
@@ -1,2 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_bucket-BWaA1PLI.js","assets/schemas-DCQXG11d.js","assets/rolldown-runtime-XKyaHtl9.js","assets/object-browser-CgVAiY2i.js","assets/QueryClientProvider-BuG40HBe.js","assets/queryOptions-Cu2lvOFG.js","assets/useQuery-D6TojLgI.js","assets/link-BX-1BPor.js","assets/matchContext-CUAcfcYw.js","assets/useRouter-B_eSHXWd.js","assets/data-table-9n7xOy9s.js","assets/useSelector-CDb-n1FW.js","assets/visuallyHidden-BHQz5bmH.js","assets/check-DPoQ9cBn.js","assets/useRegisterFieldControl-DkI655of.js","assets/useAriaLabelledBy-DStQJVTn.js","assets/useTransitionStatus-Dn-09Anc.js","assets/useValueChanged-wii0yYWk.js","assets/useControlled-NIWPmeG5.js","assets/table-BLvCYTga.js","assets/useNavigate-cMdr_EJ0.js","assets/chevron-right-DZULbaNT.js","assets/upload-uiiRWdV_.js","assets/eye-yi4wz0yh.js","assets/input-group-TEGNx6dm.js","assets/useValueAsRef-uzS1fuI8.js","assets/field-iLG94M00.js","assets/textarea-BG1iGvVs.js","assets/sliders-horizontal-DsGbsnBU.js","assets/trash-BajuWzqn.js","assets/detail-panel-ukM1Xs6L.js","assets/x-mlA6pp-8.js","assets/info-tip-Cxa2GEOd.js","assets/useScrollLock-iGnmnC-h.js","assets/composite-DysENEse.js","assets/useAnchoredPopupScrollLock-CoHshdlQ.js","assets/isElementDisabled-B7Ri5ug6.js","assets/store-BVhNJayb.js","assets/ToolbarRootContext-iMMgkc2r.js","assets/chevron-down-BgudX6bq.js","assets/copy-B9ICxw_u.js","assets/loader-circle-C7EgoGp_.js","assets/collapsible-DddM174D.js","assets/tabs-9tHEWpNn.js","assets/CompositeRoot-CmHoR-Tk.js","assets/panel-search-WG3n4DSv.js","assets/error-banner-Cahg3Ix2.js","assets/use-copy-to-clipboard-D-PxomFK.js","assets/uploads-DWKB6oFv.js","assets/aws-client-DrLAG3yq.js","assets/auth-BS4WytAe.js","assets/fetch-http-handler-C0nfkUzI.js","assets/toUtf8.browser-RsrLknAJ.js","assets/index.browser-D7GVEViY.js","assets/s3-C1ngKIr4.js","assets/useMutation-lRi0DDcO.js","assets/s3-PnGphw-P.js","assets/page-heading-ZfrnK2wu.js","assets/resource-view-COfsV2nr.js","assets/selection-bar-WnknBhRB.js","assets/use-row-selection-DB6L9NiD.js","assets/bulk-delete-CXIUB03J.js","assets/circle-x-C6c0uvb-.js","assets/alert-dialog-D9jG-rkO.js","assets/DialogTitle-nAIOTphJ.js","assets/confirm-phrase-C4Dux-0Y.js","assets/resource-delete-dialog-D3NYuO-k.js","assets/delete-confirmation-dialog-O7cqOW0Y.js"])))=>i.map(i=>d[i]);
|
||||
import{s as e}from"./schemas-DCQXG11d.js";import{n as t,t as n}from"./lazyRouteComponent-PMetaA3O.js";import{t as r}from"./searchMiddleware-Clk50hy1.js";import{U as i}from"./fetch-http-handler-C0nfkUzI.js";import{t as a}from"./s3-PnGphw-P.js";import{r as o,t as s}from"./panel-search-WG3n4DSv.js";var c=t(`/_auth/s3/ls/$bucket/`)({validateSearch:e(o),search:{middlewares:[r(s)]},loader:async({context:e,params:t})=>{await e.queryClient.query({...a(t.bucket,``),staleTime:`static`})},head:({params:e})=>({meta:[{title:`${e.bucket} | Object Storage | Mulga`}]}),component:n(()=>i(()=>import(`./_bucket-BWaA1PLI.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67])),`component`)});export{c as t};
|
||||
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_bucket-BgLSN3Tp.js","assets/schemas-CjNLFp_1.js","assets/rolldown-runtime-XKyaHtl9.js","assets/object-browser-C28HziMZ.js","assets/QueryClientProvider-CiUnq0HS.js","assets/queryOptions-BF_U72jK.js","assets/useQuery-BcsGTOcM.js","assets/link-BkUlBzrf.js","assets/matchContext-BrTj2Lqx.js","assets/useRouter-BH2du3mx.js","assets/data-table-CPb-LbjI.js","assets/useSelector-C5gpvoOl.js","assets/createLucideIcon-BexmpBS_.js","assets/check-mxX6T63H.js","assets/useRegisterFieldControl-ByyVMl5k.js","assets/useAriaLabelledBy-BTi8eo3U.js","assets/useTransitionStatus-Cyq0ANhR.js","assets/useValueChanged-Cq_W0zj9.js","assets/visuallyHidden-CmQ9ItcI.js","assets/useControlled-K3TUf9-y.js","assets/table-CkfpcZOx.js","assets/useNavigate-C8Cxi52b.js","assets/chevron-right-D3YEkqSd.js","assets/upload-DOoBJMnN.js","assets/eye-7qRQPg_Z.js","assets/input-group-DCN87RY_.js","assets/useValueAsRef-DY5n0BXa.js","assets/field-D7Rhpj9A.js","assets/textarea-BNn1cdVG.js","assets/sliders-horizontal-BfmfK97o.js","assets/trash-v7j_Lmza.js","assets/detail-panel-9quwJD4r.js","assets/x-BT7BjjO9.js","assets/info-tip-BlsL6iHw.js","assets/useScrollLock-ylk6FkWX.js","assets/composite-BSJwpiyu.js","assets/useAnchoredPopupScrollLock-D6ZKkcog.js","assets/isElementDisabled-4f1svCb6.js","assets/store-ud_TzzLS.js","assets/ToolbarRootContext-Dx0lufmd.js","assets/chevron-down-CMnru2aW.js","assets/copy-DAui9L5b.js","assets/loader-circle-Daekigmu.js","assets/collapsible-CSbzTZF0.js","assets/tabs-D3qJM6Sn.js","assets/CompositeRoot-inp2JFFI.js","assets/panel-search-BFfs0Klq.js","assets/error-banner-CoEZZQBV.js","assets/use-copy-to-clipboard-BgG7vE9f.js","assets/uploads-BKZ7RYON.js","assets/aws-client-CqvC9t1w.js","assets/auth-D8BwZ3UA.js","assets/fetch-http-handler-6B81XedT.js","assets/toUtf8.browser-RsrLknAJ.js","assets/index.browser-D7GVEViY.js","assets/s3-DIc9oJl8.js","assets/useMutation-CExmKqTG.js","assets/s3-q6zqUef5.js","assets/page-heading-BOhZ5HUE.js","assets/resource-view-p1jimfyt.js","assets/selection-bar-D2Ha-b1w.js","assets/use-row-selection-BZFNKBBc.js","assets/bulk-delete-BnNFtolc.js","assets/circle-x-BxOJ9B1g.js","assets/alert-dialog-B-pr568k.js","assets/DialogTitle-BqK2_8dh.js","assets/confirm-phrase-CsFRFZdW.js","assets/resource-delete-dialog-CdO3aw07.js","assets/delete-confirmation-dialog-Dx0nBQ7s.js"])))=>i.map(i=>d[i]);
|
||||
import{s as e}from"./schemas-CjNLFp_1.js";import{n as t,t as n}from"./lazyRouteComponent-ZWgeB3Vm.js";import{t as r}from"./searchMiddleware-ixtO4F_Y.js";import{U as i}from"./fetch-http-handler-6B81XedT.js";import{t as a}from"./s3-q6zqUef5.js";import{r as o,t as s}from"./panel-search-BFfs0Klq.js";var c=t(`/_auth/s3/ls/$bucket/`)({validateSearch:e(o),search:{middlewares:[r(s)]},loader:async({context:e,params:t})=>{await e.queryClient.query({...a(t.bucket,``),staleTime:`static`})},head:({params:e})=>({meta:[{title:`${e.bucket} | Object Storage | Mulga`}]}),component:n(()=>i(()=>import(`./_bucket-BgLSN3Tp.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68])),`component`)});export{c as t};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_clusterName-CjN9B0K3.js","assets/schemas-CjNLFp_1.js","assets/rolldown-runtime-XKyaHtl9.js","assets/useSuspenseQuery-4EOTujNd.js","assets/queryOptions-BF_U72jK.js","assets/QueryClientProvider-CiUnq0HS.js","assets/ecs-Cc9c0emt.js","assets/aws-client-CqvC9t1w.js","assets/auth-D8BwZ3UA.js","assets/fetch-http-handler-6B81XedT.js","assets/toUtf8.browser-RsrLknAJ.js","assets/index.browser-D7GVEViY.js","assets/commandBuilder-5bmRAzPK.js","assets/back-link-CAMvxfHu.js","assets/link-BkUlBzrf.js","assets/matchContext-BrTj2Lqx.js","assets/useRouter-BH2du3mx.js","assets/info-tip-BlsL6iHw.js","assets/createLucideIcon-BexmpBS_.js","assets/useScrollLock-ylk6FkWX.js","assets/useValueAsRef-DY5n0BXa.js","assets/useTransitionStatus-Cyq0ANhR.js","assets/composite-BSJwpiyu.js","assets/visuallyHidden-CmQ9ItcI.js","assets/useAnchoredPopupScrollLock-D6ZKkcog.js","assets/useValueChanged-Cq_W0zj9.js","assets/isElementDisabled-4f1svCb6.js","assets/store-ud_TzzLS.js","assets/ToolbarRootContext-Dx0lufmd.js","assets/cluster-details-DmtLSb1M.js","assets/select-JF-rfrNM.js","assets/check-mxX6T63H.js","assets/chevron-down-CMnru2aW.js","assets/useRegisterFieldControl-ByyVMl5k.js","assets/useControlled-K3TUf9-y.js","assets/clamp-D0kAXd-u.js","assets/useQuery-BcsGTOcM.js","assets/eye-7qRQPg_Z.js","assets/maximize-2-DDRaoY3J.js","assets/play-3igEYZuv.js","assets/plus-C1ej87GD.js","assets/server-ChTlBHtr.js","assets/sliders-horizontal-BfmfK97o.js","assets/cli-command-panel-qoZYjZc8.js","assets/copy-DAui9L5b.js","assets/use-copy-to-clipboard-BgG7vE9f.js","assets/trash-v7j_Lmza.js","assets/field-D7Rhpj9A.js","assets/detail-panel-9quwJD4r.js","assets/x-BT7BjjO9.js","assets/loader-circle-Daekigmu.js","assets/collapsible-CSbzTZF0.js","assets/tabs-D3qJM6Sn.js","assets/CompositeRoot-inp2JFFI.js","assets/panel-search-BFfs0Klq.js","assets/error-banner-CoEZZQBV.js","assets/ec2-CmUP7Xg7.js","assets/commandBuilder-Di3NwVH1.js","assets/ecs-CDa4_KWK.js","assets/useMutation-CExmKqTG.js","assets/signed-fetch-DhOM1D7n.js","assets/delete-confirmation-dialog-Dx0nBQ7s.js","assets/alert-dialog-B-pr568k.js","assets/DialogTitle-BqK2_8dh.js","assets/confirm-phrase-CsFRFZdW.js","assets/state-badge-BddzXZo9.js","assets/resource-tags-BPH46vkB.js","assets/tags-D0OFtoei.js","assets/commandBuilder-B5M4pd7Y.js","assets/commandBuilder-BH8PZikB.js","assets/inline-tags-XWTktJwo.js","assets/pencil-B-yB1qJe.js","assets/form-lock-CWDoMMnA.js","assets/form-panel-DzBo_1DP.js","assets/gpu-instance-type-select-CPO28261.js","assets/system-managed-Dk7ySh_E.js","assets/panel-list-DPggVdXX.js","assets/system-image-required-CcDdxipf.js","assets/rotate-cw-BMWS_bMd.js","assets/triangle-alert-C_E4c5v5.js","assets/stop-task-dialog-DKUQqmUW.js"])))=>i.map(i=>d[i]);
|
||||
import{s as e}from"./schemas-CjNLFp_1.js";import{n as t,t as n}from"./lazyRouteComponent-ZWgeB3Vm.js";import{t as r}from"./searchMiddleware-ixtO4F_Y.js";import{U as i}from"./fetch-http-handler-6B81XedT.js";import{i as a,s as o,t as s}from"./ecs-Cc9c0emt.js";import{r as c}from"./panel-search-BFfs0Klq.js";var l=t(`/_auth/ecs/(clusters)/list-clusters/$clusterName`)({validateSearch:e({tab:c.tab}),search:{middlewares:[r({tab:`overview`})]},loader:async({context:e,params:t})=>{await Promise.all([e.queryClient.query({...s(t.clusterName),staleTime:`static`}),e.queryClient.query({...a(t.clusterName),staleTime:`static`}),e.queryClient.query({...o(t.clusterName),staleTime:`static`})])},head:({params:e})=>({meta:[{title:`${e.clusterName} | ECS | Mulga`}]}),component:n(()=>i(()=>import(`./_clusterName-CjN9B0K3.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80])),`component`)});export{l as t};
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_clusterName-BHgKslIj.js","assets/schemas-DCQXG11d.js","assets/rolldown-runtime-XKyaHtl9.js","assets/cluster-details-BsCHL8iY.js","assets/select-UwOVJofC.js","assets/visuallyHidden-BHQz5bmH.js","assets/check-DPoQ9cBn.js","assets/chevron-down-BgudX6bq.js","assets/useScrollLock-iGnmnC-h.js","assets/useValueAsRef-uzS1fuI8.js","assets/useTransitionStatus-Dn-09Anc.js","assets/composite-DysENEse.js","assets/useRegisterFieldControl-DkI655of.js","assets/useValueChanged-wii0yYWk.js","assets/useAnchoredPopupScrollLock-CoHshdlQ.js","assets/isElementDisabled-B7Ri5ug6.js","assets/useControlled-NIWPmeG5.js","assets/ToolbarRootContext-iMMgkc2r.js","assets/clamp-D0kAXd-u.js","assets/QueryClientProvider-BuG40HBe.js","assets/useMutation-lRi0DDcO.js","assets/queryOptions-Cu2lvOFG.js","assets/useQuery-D6TojLgI.js","assets/useSuspenseQuery--dusmE-Q.js","assets/link-BX-1BPor.js","assets/matchContext-CUAcfcYw.js","assets/useRouter-B_eSHXWd.js","assets/circle-arrow-up-CFARPO9V.js","assets/eye-yi4wz0yh.js","assets/maximize-2-BAlKoEj8.js","assets/plus-BK_7eO_G.js","assets/sliders-horizontal-DsGbsnBU.js","assets/cli-command-panel-Do14gxB9.js","assets/copy-B9ICxw_u.js","assets/use-copy-to-clipboard-D-PxomFK.js","assets/trash-BajuWzqn.js","assets/triangle-alert-Ckml5FoO.js","assets/field-iLG94M00.js","assets/detail-panel-ukM1Xs6L.js","assets/x-mlA6pp-8.js","assets/info-tip-Cxa2GEOd.js","assets/store-BVhNJayb.js","assets/loader-circle-C7EgoGp_.js","assets/collapsible-DddM174D.js","assets/tabs-9tHEWpNn.js","assets/CompositeRoot-CmHoR-Tk.js","assets/panel-search-WG3n4DSv.js","assets/error-banner-Cahg3Ix2.js","assets/auth-BS4WytAe.js","assets/aws-client-DrLAG3yq.js","assets/fetch-http-handler-C0nfkUzI.js","assets/toUtf8.browser-RsrLknAJ.js","assets/index.browser-D7GVEViY.js","assets/tags-CXDQrv7y.js","assets/commandBuilder-M7C_FWzS.js","assets/commandBuilder-zZSrq900.js","assets/commandBuilder-CTdNRogs.js","assets/commandBuilder-lcvi73e2.js","assets/ec2-DcwXjFvB.js","assets/eks-SKaqX__X.js","assets/iam-CCgnxhTh.js","assets/json-DSPw_Fae.js","assets/delete-confirmation-dialog-O7cqOW0Y.js","assets/alert-dialog-D9jG-rkO.js","assets/DialogTitle-nAIOTphJ.js","assets/confirm-phrase-C4Dux-0Y.js","assets/state-badge-CQPRxt3J.js","assets/subnet-calculator-DDrAb5xp.js","assets/resource-tags-mPu3NXlt.js","assets/inline-tags-BKqbcCST.js","assets/pencil-DLzYMCIc.js","assets/form-lock-D8Cu0Sfu.js","assets/form-panel-DH2TNbb8.js","assets/gpu-instance-type-select-DsB5oPSo.js","assets/index.esm-C31mnnYl.js","assets/zod-nOHMBkds.js","assets/form-fields-CcTBfvl9.js","assets/switch-list-X53OOONN.js","assets/switch-CFqwHFZR.js","assets/useAriaLabelledBy-DStQJVTn.js","assets/badge-DmU0agld.js","assets/useRender-B8US0bW6.js","assets/json-editor-Ch9m2EYE.js","assets/textarea-BG1iGvVs.js"])))=>i.map(i=>d[i]);
|
||||
import{s as e}from"./schemas-DCQXG11d.js";import{n as t,t as n}from"./lazyRouteComponent-PMetaA3O.js";import{t as r}from"./searchMiddleware-Clk50hy1.js";import{U as i}from"./fetch-http-handler-C0nfkUzI.js";import{s as a,t as o,u as s}from"./eks-SKaqX__X.js";import{r as c}from"./panel-search-WG3n4DSv.js";var l=t(`/_auth/eks/(clusters)/list-clusters/$clusterName`)({validateSearch:e({tab:c.tab}),search:{middlewares:[r({tab:`overview`})]},loader:async({context:e,params:t})=>{await Promise.all([e.queryClient.query({...a(t.clusterName),staleTime:`static`}),e.queryClient.query({...s(t.clusterName),staleTime:`static`}),e.queryClient.query({...o(t.clusterName),staleTime:`static`})])},head:({params:e})=>({meta:[{title:`${e.clusterName} | EKS | Mulga`}]}),component:n(()=>i(()=>import(`./_clusterName-BHgKslIj.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83])),`component`)});export{l as t};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{It as e,Mt as t}from"./schemas-DCQXG11d.js";import{t as n}from"./useSuspenseQuery--dusmE-Q.js";import{t as r}from"./ecs-ChRhLx72.js";import{t as i}from"./_clusterName-L3DvZP6y.js";import{t as a}from"./back-link-B8ejy5Fm.js";import{t as o}from"./cluster-details-CIU1FpYa.js";var s=t(),c=e();function l(){let e=(0,s.c)(11),{clusterName:t}=i.useParams(),{tab:l}=i.useSearch(),u=i.useNavigate(),d;e[0]===t?d=e[1]:(d=r(t),e[0]=t,e[1]=d);let{data:f}=n(d);if(!f){let t;return e[2]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,c.jsxs)(c.Fragment,{children:[(0,c.jsx)(a,{to:`/ecs/list-clusters`,children:`Back to clusters`}),(0,c.jsx)(`p`,{className:`text-muted-foreground`,children:`Cluster not found.`})]}),e[2]=t):t=e[2],t}let p,m;e[3]===u?(p=e[4],m=e[5]):(p=()=>{u({to:`/ecs/list-clusters`})},m=e=>{u({search:{tab:e},replace:!0})},e[3]=u,e[4]=p,e[5]=m);let h;return e[6]!==f||e[7]!==p||e[8]!==m||e[9]!==l?(h=(0,c.jsx)(o,{cluster:f,layout:`page`,onClose:p,onTabChange:m,tab:l}),e[6]=f,e[7]=p,e[8]=m,e[9]=l,e[10]=h):h=e[10],h}export{l as component};
|
||||
import{It as e,Mt as t}from"./schemas-CjNLFp_1.js";import{t as n}from"./useSuspenseQuery-4EOTujNd.js";import{t as r}from"./ecs-Cc9c0emt.js";import{t as i}from"./_clusterName-BVNcTb9Y.js";import{t as a}from"./back-link-CAMvxfHu.js";import{t as o}from"./cluster-details-DmtLSb1M.js";var s=t(),c=e();function l(){let e=(0,s.c)(11),{clusterName:t}=i.useParams(),{tab:l}=i.useSearch(),u=i.useNavigate(),d;e[0]===t?d=e[1]:(d=r(t),e[0]=t,e[1]=d);let{data:f}=n(d);if(!f){let t;return e[2]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,c.jsxs)(c.Fragment,{children:[(0,c.jsx)(a,{to:`/ecs/list-clusters`,children:`Back to clusters`}),(0,c.jsx)(`p`,{className:`text-muted-foreground`,children:`Cluster not found.`})]}),e[2]=t):t=e[2],t}let p,m;e[3]===u?(p=e[4],m=e[5]):(p=()=>{u({to:`/ecs/list-clusters`})},m=e=>{u({search:{tab:e},replace:!0})},e[3]=u,e[4]=p,e[5]=m);let h;return e[6]!==f||e[7]!==p||e[8]!==m||e[9]!==l?(h=(0,c.jsx)(o,{cluster:f,layout:`page`,onClose:p,onTabChange:m,tab:l}),e[6]=f,e[7]=p,e[8]=m,e[9]=l,e[10]=h):h=e[10],h}export{l as component};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_clusterName-LL2LfUZK.js","assets/schemas-CjNLFp_1.js","assets/rolldown-runtime-XKyaHtl9.js","assets/cluster-details-BUiWZzLX.js","assets/select-JF-rfrNM.js","assets/createLucideIcon-BexmpBS_.js","assets/check-mxX6T63H.js","assets/chevron-down-CMnru2aW.js","assets/useScrollLock-ylk6FkWX.js","assets/useValueAsRef-DY5n0BXa.js","assets/useTransitionStatus-Cyq0ANhR.js","assets/composite-BSJwpiyu.js","assets/visuallyHidden-CmQ9ItcI.js","assets/useRegisterFieldControl-ByyVMl5k.js","assets/useValueChanged-Cq_W0zj9.js","assets/useAnchoredPopupScrollLock-D6ZKkcog.js","assets/isElementDisabled-4f1svCb6.js","assets/useControlled-K3TUf9-y.js","assets/ToolbarRootContext-Dx0lufmd.js","assets/clamp-D0kAXd-u.js","assets/QueryClientProvider-CiUnq0HS.js","assets/useMutation-CExmKqTG.js","assets/queryOptions-BF_U72jK.js","assets/useQuery-BcsGTOcM.js","assets/useSuspenseQuery-4EOTujNd.js","assets/link-BkUlBzrf.js","assets/matchContext-BrTj2Lqx.js","assets/useRouter-BH2du3mx.js","assets/circle-arrow-up-CjL8uPcA.js","assets/eye-7qRQPg_Z.js","assets/maximize-2-DDRaoY3J.js","assets/plus-C1ej87GD.js","assets/sliders-horizontal-BfmfK97o.js","assets/cli-command-panel-qoZYjZc8.js","assets/copy-DAui9L5b.js","assets/use-copy-to-clipboard-BgG7vE9f.js","assets/trash-v7j_Lmza.js","assets/triangle-alert-C_E4c5v5.js","assets/field-D7Rhpj9A.js","assets/detail-panel-9quwJD4r.js","assets/x-BT7BjjO9.js","assets/info-tip-BlsL6iHw.js","assets/store-ud_TzzLS.js","assets/loader-circle-Daekigmu.js","assets/collapsible-CSbzTZF0.js","assets/tabs-D3qJM6Sn.js","assets/CompositeRoot-inp2JFFI.js","assets/panel-search-BFfs0Klq.js","assets/error-banner-CoEZZQBV.js","assets/auth-D8BwZ3UA.js","assets/aws-client-CqvC9t1w.js","assets/fetch-http-handler-6B81XedT.js","assets/toUtf8.browser-RsrLknAJ.js","assets/index.browser-D7GVEViY.js","assets/tags-D0OFtoei.js","assets/commandBuilder-Di3NwVH1.js","assets/commandBuilder-5bmRAzPK.js","assets/commandBuilder-B5M4pd7Y.js","assets/commandBuilder-BH8PZikB.js","assets/ec2-CmUP7Xg7.js","assets/eks-vd6jUfk-.js","assets/iam-DxKMjFfv.js","assets/json-C9HAqAGZ.js","assets/delete-confirmation-dialog-Dx0nBQ7s.js","assets/alert-dialog-B-pr568k.js","assets/DialogTitle-BqK2_8dh.js","assets/confirm-phrase-CsFRFZdW.js","assets/state-badge-BddzXZo9.js","assets/subnet-calculator-DDrAb5xp.js","assets/resource-tags-BPH46vkB.js","assets/inline-tags-XWTktJwo.js","assets/pencil-B-yB1qJe.js","assets/form-lock-CWDoMMnA.js","assets/form-panel-DzBo_1DP.js","assets/gpu-instance-type-select-CPO28261.js","assets/index.esm-T443ih_m.js","assets/zod-CDMslssh.js","assets/form-fields-DYRSBBjY.js","assets/switch-list-D-LP0GfS.js","assets/switch-DrHy9fb4.js","assets/useAriaLabelledBy-BTi8eo3U.js","assets/badge-B2ciE6Zo.js","assets/useRender-BUG2ZuWl.js","assets/json-editor-8a71gD9L.js","assets/textarea-BNn1cdVG.js"])))=>i.map(i=>d[i]);
|
||||
import{s as e}from"./schemas-CjNLFp_1.js";import{n as t,t as n}from"./lazyRouteComponent-ZWgeB3Vm.js";import{t as r}from"./searchMiddleware-ixtO4F_Y.js";import{U as i}from"./fetch-http-handler-6B81XedT.js";import{s as a,t as o,u as s}from"./eks-vd6jUfk-.js";import{r as c}from"./panel-search-BFfs0Klq.js";var l=t(`/_auth/eks/(clusters)/list-clusters/$clusterName`)({validateSearch:e({tab:c.tab}),search:{middlewares:[r({tab:`overview`})]},loader:async({context:e,params:t})=>{await Promise.all([e.queryClient.query({...a(t.clusterName),staleTime:`static`}),e.queryClient.query({...s(t.clusterName),staleTime:`static`}),e.queryClient.query({...o(t.clusterName),staleTime:`static`})])},head:({params:e})=>({meta:[{title:`${e.clusterName} | EKS | Mulga`}]}),component:n(()=>i(()=>import(`./_clusterName-LL2LfUZK.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84])),`component`)});export{l as t};
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_clusterName-DSl6aMGV.js","assets/schemas-DCQXG11d.js","assets/rolldown-runtime-XKyaHtl9.js","assets/useSuspenseQuery--dusmE-Q.js","assets/queryOptions-Cu2lvOFG.js","assets/QueryClientProvider-BuG40HBe.js","assets/ecs-ChRhLx72.js","assets/aws-client-DrLAG3yq.js","assets/auth-BS4WytAe.js","assets/fetch-http-handler-C0nfkUzI.js","assets/toUtf8.browser-RsrLknAJ.js","assets/index.browser-D7GVEViY.js","assets/commandBuilder-zZSrq900.js","assets/back-link-B8ejy5Fm.js","assets/link-BX-1BPor.js","assets/matchContext-CUAcfcYw.js","assets/useRouter-B_eSHXWd.js","assets/info-tip-Cxa2GEOd.js","assets/visuallyHidden-BHQz5bmH.js","assets/useScrollLock-iGnmnC-h.js","assets/useValueAsRef-uzS1fuI8.js","assets/useTransitionStatus-Dn-09Anc.js","assets/composite-DysENEse.js","assets/useAnchoredPopupScrollLock-CoHshdlQ.js","assets/useValueChanged-wii0yYWk.js","assets/isElementDisabled-B7Ri5ug6.js","assets/store-BVhNJayb.js","assets/ToolbarRootContext-iMMgkc2r.js","assets/cluster-details-CIU1FpYa.js","assets/select-UwOVJofC.js","assets/check-DPoQ9cBn.js","assets/chevron-down-BgudX6bq.js","assets/useRegisterFieldControl-DkI655of.js","assets/useControlled-NIWPmeG5.js","assets/clamp-D0kAXd-u.js","assets/useQuery-D6TojLgI.js","assets/eye-yi4wz0yh.js","assets/maximize-2-BAlKoEj8.js","assets/play-qJowxtRp.js","assets/plus-BK_7eO_G.js","assets/server-D03m5B1l.js","assets/sliders-horizontal-DsGbsnBU.js","assets/cli-command-panel-Do14gxB9.js","assets/copy-B9ICxw_u.js","assets/use-copy-to-clipboard-D-PxomFK.js","assets/trash-BajuWzqn.js","assets/field-iLG94M00.js","assets/detail-panel-ukM1Xs6L.js","assets/x-mlA6pp-8.js","assets/loader-circle-C7EgoGp_.js","assets/collapsible-DddM174D.js","assets/tabs-9tHEWpNn.js","assets/CompositeRoot-CmHoR-Tk.js","assets/panel-search-WG3n4DSv.js","assets/error-banner-Cahg3Ix2.js","assets/ec2-DcwXjFvB.js","assets/commandBuilder-M7C_FWzS.js","assets/ecs-CSk0DWnm.js","assets/useMutation-lRi0DDcO.js","assets/signed-fetch-BqrfDScu.js","assets/delete-confirmation-dialog-O7cqOW0Y.js","assets/alert-dialog-D9jG-rkO.js","assets/DialogTitle-nAIOTphJ.js","assets/confirm-phrase-C4Dux-0Y.js","assets/state-badge-CQPRxt3J.js","assets/resource-tags-mPu3NXlt.js","assets/tags-CXDQrv7y.js","assets/commandBuilder-CTdNRogs.js","assets/commandBuilder-lcvi73e2.js","assets/inline-tags-BKqbcCST.js","assets/pencil-DLzYMCIc.js","assets/form-lock-D8Cu0Sfu.js","assets/form-panel-DH2TNbb8.js","assets/gpu-instance-type-select-DsB5oPSo.js","assets/system-managed-Dk7ySh_E.js","assets/panel-list-ChJfng6E.js","assets/system-image-required-qTqRM69m.js","assets/rotate-cw-DhHI5fTM.js","assets/triangle-alert-Ckml5FoO.js","assets/stop-task-dialog-C0_MtdlP.js"])))=>i.map(i=>d[i]);
|
||||
import{s as e}from"./schemas-DCQXG11d.js";import{n as t,t as n}from"./lazyRouteComponent-PMetaA3O.js";import{t as r}from"./searchMiddleware-Clk50hy1.js";import{U as i}from"./fetch-http-handler-C0nfkUzI.js";import{i as a,s as o,t as s}from"./ecs-ChRhLx72.js";import{r as c}from"./panel-search-WG3n4DSv.js";var l=t(`/_auth/ecs/(clusters)/list-clusters/$clusterName`)({validateSearch:e({tab:c.tab}),search:{middlewares:[r({tab:`overview`})]},loader:async({context:e,params:t})=>{await Promise.all([e.queryClient.query({...s(t.clusterName),staleTime:`static`}),e.queryClient.query({...a(t.clusterName),staleTime:`static`}),e.queryClient.query({...o(t.clusterName),staleTime:`static`})])},head:({params:e})=>({meta:[{title:`${e.clusterName} | ECS | Mulga`}]}),component:n(()=>i(()=>import(`./_clusterName-DSl6aMGV.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79])),`component`)});export{l as t};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{It as e,Mt as t}from"./schemas-DCQXG11d.js";import{t as n}from"./cluster-details-BsCHL8iY.js";import{t as r}from"./_clusterName-CU4kQY1v.js";var i=t(),a=e();function o(){let e=(0,i.c)(8),{clusterName:t}=r.useParams(),{tab:o}=r.useSearch(),s=r.useNavigate(),c,l;e[0]===s?(c=e[1],l=e[2]):(c=()=>{s({to:`/eks/list-clusters`})},l=e=>{s({search:{tab:e},replace:!0})},e[0]=s,e[1]=c,e[2]=l);let u;return e[3]!==t||e[4]!==c||e[5]!==l||e[6]!==o?(u=(0,a.jsx)(n,{clusterName:t,layout:`page`,onClose:c,onTabChange:l,tab:o}),e[3]=t,e[4]=c,e[5]=l,e[6]=o,e[7]=u):u=e[7],u}export{o as component};
|
||||
import{It as e,Mt as t}from"./schemas-CjNLFp_1.js";import{t as n}from"./cluster-details-BUiWZzLX.js";import{t as r}from"./_clusterName-CzwCsoVy.js";var i=t(),a=e();function o(){let e=(0,i.c)(8),{clusterName:t}=r.useParams(),{tab:o}=r.useSearch(),s=r.useNavigate(),c,l;e[0]===s?(c=e[1],l=e[2]):(c=()=>{s({to:`/eks/list-clusters`})},l=e=>{s({search:{tab:e},replace:!0})},e[0]=s,e[1]=c,e[2]=l);let u;return e[3]!==t||e[4]!==c||e[5]!==l||e[6]!==o?(u=(0,a.jsx)(n,{clusterName:t,layout:`page`,onClose:c,onTabChange:l,tab:o}),e[3]=t,e[4]=c,e[5]=l,e[6]=o,e[7]=u):u=e[7],u}export{o as component};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{It as e,Mt as t}from"./schemas-DCQXG11d.js";import{t as n}from"./useSuspenseQuery--dusmE-Q.js";import{t as r}from"./group-details-BS-htUnx.js";import{s as i}from"./iam-CCgnxhTh.js";import{t as a}from"./_groupName-DJuH2Gml.js";import{t as o}from"./back-link-B8ejy5Fm.js";var s=t(),c=e();function l(){let e=(0,s.c)(11),{groupName:t}=a.useParams(),{tab:l}=a.useSearch(),u=a.useNavigate(),d;e[0]===t?d=e[1]:(d=i(t),e[0]=t,e[1]=d);let{data:f}=n(d),p=f.Group;if(!p){let t;return e[2]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,c.jsxs)(c.Fragment,{children:[(0,c.jsx)(o,{to:`/iam/list-groups`,children:`Back to groups`}),(0,c.jsx)(`p`,{className:`text-muted-foreground`,children:`Group not found.`})]}),e[2]=t):t=e[2],t}let m,h;e[3]===u?(m=e[4],h=e[5]):(m=()=>{u({to:`/iam/list-groups`})},h=e=>{u({search:{tab:e},replace:!0})},e[3]=u,e[4]=m,e[5]=h);let g;return e[6]!==p||e[7]!==m||e[8]!==h||e[9]!==l?(g=(0,c.jsx)(r,{layout:`page`,onClose:m,onTabChange:h,group:p,tab:l}),e[6]=p,e[7]=m,e[8]=h,e[9]=l,e[10]=g):g=e[10],g}export{l as component};
|
||||
import{It as e,Mt as t}from"./schemas-CjNLFp_1.js";import{t as n}from"./useSuspenseQuery-4EOTujNd.js";import{t as r}from"./group-details-D8ocPVig.js";import{s as i}from"./iam-DxKMjFfv.js";import{t as a}from"./_groupName-CSUGwrSF.js";import{t as o}from"./back-link-CAMvxfHu.js";var s=t(),c=e();function l(){let e=(0,s.c)(11),{groupName:t}=a.useParams(),{tab:l}=a.useSearch(),u=a.useNavigate(),d;e[0]===t?d=e[1]:(d=i(t),e[0]=t,e[1]=d);let{data:f}=n(d),p=f.Group;if(!p){let t;return e[2]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,c.jsxs)(c.Fragment,{children:[(0,c.jsx)(o,{to:`/iam/list-groups`,children:`Back to groups`}),(0,c.jsx)(`p`,{className:`text-muted-foreground`,children:`Group not found.`})]}),e[2]=t):t=e[2],t}let m,h;e[3]===u?(m=e[4],h=e[5]):(m=()=>{u({to:`/iam/list-groups`})},h=e=>{u({search:{tab:e},replace:!0})},e[3]=u,e[4]=m,e[5]=h);let g;return e[6]!==p||e[7]!==m||e[8]!==h||e[9]!==l?(g=(0,c.jsx)(r,{layout:`page`,onClose:m,onTabChange:h,group:p,tab:l}),e[6]=p,e[7]=m,e[8]=h,e[9]=l,e[10]=g):g=e[10],g}export{l as component};
|
||||
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_groupName-BShTDcvC.js","assets/schemas-CjNLFp_1.js","assets/rolldown-runtime-XKyaHtl9.js","assets/useSuspenseQuery-4EOTujNd.js","assets/queryOptions-BF_U72jK.js","assets/QueryClientProvider-CiUnq0HS.js","assets/group-details-D8ocPVig.js","assets/link-BkUlBzrf.js","assets/matchContext-BrTj2Lqx.js","assets/useRouter-BH2du3mx.js","assets/createLucideIcon-BexmpBS_.js","assets/eye-7qRQPg_Z.js","assets/maximize-2-DDRaoY3J.js","assets/sliders-horizontal-BfmfK97o.js","assets/trash-v7j_Lmza.js","assets/useValueAsRef-DY5n0BXa.js","assets/useTransitionStatus-Cyq0ANhR.js","assets/detail-panel-9quwJD4r.js","assets/x-BT7BjjO9.js","assets/info-tip-BlsL6iHw.js","assets/useScrollLock-ylk6FkWX.js","assets/composite-BSJwpiyu.js","assets/visuallyHidden-CmQ9ItcI.js","assets/useAnchoredPopupScrollLock-D6ZKkcog.js","assets/useValueChanged-Cq_W0zj9.js","assets/isElementDisabled-4f1svCb6.js","assets/store-ud_TzzLS.js","assets/ToolbarRootContext-Dx0lufmd.js","assets/check-mxX6T63H.js","assets/chevron-down-CMnru2aW.js","assets/copy-DAui9L5b.js","assets/loader-circle-Daekigmu.js","assets/collapsible-CSbzTZF0.js","assets/useControlled-K3TUf9-y.js","assets/tabs-D3qJM6Sn.js","assets/CompositeRoot-inp2JFFI.js","assets/panel-search-BFfs0Klq.js","assets/error-banner-CoEZZQBV.js","assets/use-copy-to-clipboard-BgG7vE9f.js","assets/iam-CSrhZbUE.js","assets/useMutation-CExmKqTG.js","assets/aws-client-CqvC9t1w.js","assets/auth-D8BwZ3UA.js","assets/fetch-http-handler-6B81XedT.js","assets/toUtf8.browser-RsrLknAJ.js","assets/index.browser-D7GVEViY.js","assets/commandBuilder-B5M4pd7Y.js","assets/iam-DxKMjFfv.js","assets/json-C9HAqAGZ.js","assets/delete-confirmation-dialog-Dx0nBQ7s.js","assets/alert-dialog-B-pr568k.js","assets/DialogTitle-BqK2_8dh.js","assets/confirm-phrase-CsFRFZdW.js","assets/field-D7Rhpj9A.js","assets/useRegisterFieldControl-ByyVMl5k.js","assets/resource-delete-dialog-CdO3aw07.js","assets/iam-items-tMUMA4X_.js","assets/inline-policies-panel-CWUmar2b.js","assets/useQuery-BcsGTOcM.js","assets/plus-C1ej87GD.js","assets/unlink-gUxCBQ8o.js","assets/json-editor-8a71gD9L.js","assets/textarea-BNn1cdVG.js","assets/iam-CKTCgpOX.js","assets/delete-prerequisites-BjEJirR1.js","assets/back-link-CAMvxfHu.js"])))=>i.map(i=>d[i]);
|
||||
import{s as e}from"./schemas-CjNLFp_1.js";import{n as t,t as n}from"./lazyRouteComponent-ZWgeB3Vm.js";import{t as r}from"./searchMiddleware-ixtO4F_Y.js";import{U as i}from"./fetch-http-handler-6B81XedT.js";import{C as a,a as o,n as s,p as c,s as l}from"./iam-DxKMjFfv.js";import{r as u}from"./panel-search-BFfs0Klq.js";var d=t(`/_auth/iam/(groups)/list-groups/$groupName`)({validateSearch:e({tab:u.tab}),search:{middlewares:[r({tab:`overview`})]},loader:async({context:e,params:t})=>{await Promise.all([e.queryClient.query({...l(t.groupName),staleTime:`static`}),e.queryClient.query({...s(t.groupName),staleTime:`static`}),e.queryClient.query({...c,staleTime:`static`}),e.queryClient.query({...a,staleTime:`static`}),e.queryClient.query({...o(t.groupName),staleTime:`static`})])},head:({params:e})=>({meta:[{title:`${e.groupName} | IAM | Mulga`}]}),component:n(()=>i(()=>import(`./_groupName-BShTDcvC.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65])),`component`)});export{d as t};
|
||||
@@ -1,2 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_groupName-BXF44ldp.js","assets/schemas-DCQXG11d.js","assets/rolldown-runtime-XKyaHtl9.js","assets/useSuspenseQuery--dusmE-Q.js","assets/queryOptions-Cu2lvOFG.js","assets/QueryClientProvider-BuG40HBe.js","assets/group-details-BS-htUnx.js","assets/link-BX-1BPor.js","assets/matchContext-CUAcfcYw.js","assets/useRouter-B_eSHXWd.js","assets/visuallyHidden-BHQz5bmH.js","assets/eye-yi4wz0yh.js","assets/maximize-2-BAlKoEj8.js","assets/sliders-horizontal-DsGbsnBU.js","assets/trash-BajuWzqn.js","assets/useValueAsRef-uzS1fuI8.js","assets/useTransitionStatus-Dn-09Anc.js","assets/detail-panel-ukM1Xs6L.js","assets/x-mlA6pp-8.js","assets/info-tip-Cxa2GEOd.js","assets/useScrollLock-iGnmnC-h.js","assets/composite-DysENEse.js","assets/useAnchoredPopupScrollLock-CoHshdlQ.js","assets/useValueChanged-wii0yYWk.js","assets/isElementDisabled-B7Ri5ug6.js","assets/store-BVhNJayb.js","assets/ToolbarRootContext-iMMgkc2r.js","assets/check-DPoQ9cBn.js","assets/chevron-down-BgudX6bq.js","assets/copy-B9ICxw_u.js","assets/loader-circle-C7EgoGp_.js","assets/collapsible-DddM174D.js","assets/useControlled-NIWPmeG5.js","assets/tabs-9tHEWpNn.js","assets/CompositeRoot-CmHoR-Tk.js","assets/panel-search-WG3n4DSv.js","assets/error-banner-Cahg3Ix2.js","assets/use-copy-to-clipboard-D-PxomFK.js","assets/iam-BTU8UicC.js","assets/useMutation-lRi0DDcO.js","assets/aws-client-DrLAG3yq.js","assets/auth-BS4WytAe.js","assets/fetch-http-handler-C0nfkUzI.js","assets/toUtf8.browser-RsrLknAJ.js","assets/index.browser-D7GVEViY.js","assets/commandBuilder-CTdNRogs.js","assets/iam-CCgnxhTh.js","assets/json-DSPw_Fae.js","assets/delete-confirmation-dialog-O7cqOW0Y.js","assets/alert-dialog-D9jG-rkO.js","assets/DialogTitle-nAIOTphJ.js","assets/confirm-phrase-C4Dux-0Y.js","assets/field-iLG94M00.js","assets/useRegisterFieldControl-DkI655of.js","assets/resource-delete-dialog-D3NYuO-k.js","assets/iam-items-Ww3TvSwm.js","assets/inline-policies-panel-C0md4z74.js","assets/useQuery-D6TojLgI.js","assets/plus-BK_7eO_G.js","assets/unlink-BigvXWQp.js","assets/json-editor-Ch9m2EYE.js","assets/textarea-BG1iGvVs.js","assets/iam-BG-N60hp.js","assets/delete-prerequisites-BjEJirR1.js","assets/back-link-B8ejy5Fm.js"])))=>i.map(i=>d[i]);
|
||||
import{s as e}from"./schemas-DCQXG11d.js";import{n as t,t as n}from"./lazyRouteComponent-PMetaA3O.js";import{t as r}from"./searchMiddleware-Clk50hy1.js";import{U as i}from"./fetch-http-handler-C0nfkUzI.js";import{C as a,a as o,n as s,p as c,s as l}from"./iam-CCgnxhTh.js";import{r as u}from"./panel-search-WG3n4DSv.js";var d=t(`/_auth/iam/(groups)/list-groups/$groupName`)({validateSearch:e({tab:u.tab}),search:{middlewares:[r({tab:`overview`})]},loader:async({context:e,params:t})=>{await Promise.all([e.queryClient.query({...l(t.groupName),staleTime:`static`}),e.queryClient.query({...s(t.groupName),staleTime:`static`}),e.queryClient.query({...c,staleTime:`static`}),e.queryClient.query({...a,staleTime:`static`}),e.queryClient.query({...o(t.groupName),staleTime:`static`})])},head:({params:e})=>({meta:[{title:`${e.groupName} | IAM | Mulga`}]}),component:n(()=>i(()=>import(`./_groupName-BXF44ldp.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64])),`component`)});export{d as t};
|
||||
+1
@@ -0,0 +1 @@
|
||||
import{It as e,Mt as t}from"./schemas-CjNLFp_1.js";import{t as n}from"./useSuspenseQuery-4EOTujNd.js";import{n as r}from"./bedrock-BpgQsPgj.js";import{p as i}from"./bedrock-DReeYQnm.js";import{t as a}from"./_guardrailId.edit-DC0jIcj6.js";import{t as o}from"./guardrail-form-page-BUM9g-hD.js";var s=t(),c=e();function l(){let e=(0,s.c)(7),{guardrailId:t}=a.useParams(),l;e[0]===t?l=e[1]:(l=r(t),e[0]=t,e[1]=l);let{data:u}=n(l),d;e[2]===u?d=e[3]:(d=i(u),e[2]=u,e[3]=d);let f;return e[4]!==t||e[5]!==d?(f=(0,c.jsx)(o,{defaultValues:d,guardrailId:t,mode:`edit`}),e[4]=t,e[5]=d,e[6]=f):f=e[6],f}export{l as component};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_guardrailId.edit-BFDQFkcZ.js","assets/schemas-CjNLFp_1.js","assets/rolldown-runtime-XKyaHtl9.js","assets/useSuspenseQuery-4EOTujNd.js","assets/queryOptions-BF_U72jK.js","assets/QueryClientProvider-CiUnq0HS.js","assets/bedrock-BpgQsPgj.js","assets/aws-client-CqvC9t1w.js","assets/auth-D8BwZ3UA.js","assets/fetch-http-handler-6B81XedT.js","assets/toUtf8.browser-RsrLknAJ.js","assets/index.browser-D7GVEViY.js","assets/bedrock-DReeYQnm.js","assets/useMutation-CExmKqTG.js","assets/tags-CIkbzXUo.js","assets/guardrail-form-page-BUM9g-hD.js","assets/useNavigate-C8Cxi52b.js","assets/useRouter-BH2du3mx.js","assets/plus-C1ej87GD.js","assets/createLucideIcon-BexmpBS_.js","assets/cli-command-panel-qoZYjZc8.js","assets/check-mxX6T63H.js","assets/chevron-down-CMnru2aW.js","assets/copy-DAui9L5b.js","assets/useValueAsRef-DY5n0BXa.js","assets/useTransitionStatus-Cyq0ANhR.js","assets/use-copy-to-clipboard-BgG7vE9f.js","assets/trash-v7j_Lmza.js","assets/field-D7Rhpj9A.js","assets/useRegisterFieldControl-ByyVMl5k.js","assets/useValueChanged-Cq_W0zj9.js","assets/switch-DrHy9fb4.js","assets/useAriaLabelledBy-BTi8eo3U.js","assets/visuallyHidden-CmQ9ItcI.js","assets/useControlled-K3TUf9-y.js","assets/textarea-BNn1cdVG.js","assets/page-heading-BOhZ5HUE.js","assets/form-lock-CWDoMMnA.js","assets/loader-circle-Daekigmu.js","assets/error-banner-CoEZZQBV.js","assets/index.esm-T443ih_m.js","assets/zod-CDMslssh.js","assets/form-fields-DYRSBBjY.js","assets/select-JF-rfrNM.js","assets/useScrollLock-ylk6FkWX.js","assets/composite-BSJwpiyu.js","assets/useAnchoredPopupScrollLock-D6ZKkcog.js","assets/isElementDisabled-4f1svCb6.js","assets/ToolbarRootContext-Dx0lufmd.js","assets/clamp-D0kAXd-u.js","assets/info-tip-BlsL6iHw.js","assets/store-ud_TzzLS.js","assets/back-link-CAMvxfHu.js","assets/link-BkUlBzrf.js","assets/matchContext-BrTj2Lqx.js","assets/tags-field-array-DEBy5n6A.js"])))=>i.map(i=>d[i]);
|
||||
import{n as e,t}from"./lazyRouteComponent-ZWgeB3Vm.js";import{U as n}from"./fetch-http-handler-6B81XedT.js";import{n as r}from"./bedrock-BpgQsPgj.js";var i=e(`/_auth/bedrock/(guardrails)/list-guardrails/$guardrailId/edit`)({loader:async({context:e,params:t})=>{await e.queryClient.query({...r(t.guardrailId),staleTime:`static`})},head:({params:e})=>({meta:[{title:`Edit ${e.guardrailId} | Guardrail | Mulga`}]}),component:t(()=>n(()=>import(`./_guardrailId.edit-BFDQFkcZ.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55])),`component`)});export{i as t};
|
||||
-1
@@ -1 +0,0 @@
|
||||
import{It as e,Mt as t}from"./schemas-DCQXG11d.js";import{t as n}from"./useSuspenseQuery--dusmE-Q.js";import{n as r}from"./bedrock-DaOk_4Nm.js";import{p as i}from"./bedrock-D5w21ls0.js";import{t as a}from"./_guardrailId.edit-xP9dNDnv.js";import{t as o}from"./guardrail-form-page-Clo1NJWK.js";var s=t(),c=e();function l(){let e=(0,s.c)(7),{guardrailId:t}=a.useParams(),l;e[0]===t?l=e[1]:(l=r(t),e[0]=t,e[1]=l);let{data:u}=n(l),d;e[2]===u?d=e[3]:(d=i(u),e[2]=u,e[3]=d);let f;return e[4]!==t||e[5]!==d?(f=(0,c.jsx)(o,{defaultValues:d,guardrailId:t,mode:`edit`}),e[4]=t,e[5]=d,e[6]=f):f=e[6],f}export{l as component};
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_guardrailId.edit-spJljoC2.js","assets/schemas-DCQXG11d.js","assets/rolldown-runtime-XKyaHtl9.js","assets/useSuspenseQuery--dusmE-Q.js","assets/queryOptions-Cu2lvOFG.js","assets/QueryClientProvider-BuG40HBe.js","assets/bedrock-DaOk_4Nm.js","assets/aws-client-DrLAG3yq.js","assets/auth-BS4WytAe.js","assets/fetch-http-handler-C0nfkUzI.js","assets/toUtf8.browser-RsrLknAJ.js","assets/index.browser-D7GVEViY.js","assets/bedrock-D5w21ls0.js","assets/useMutation-lRi0DDcO.js","assets/tags-Ki2a29eH.js","assets/guardrail-form-page-Clo1NJWK.js","assets/useNavigate-cMdr_EJ0.js","assets/useRouter-B_eSHXWd.js","assets/plus-BK_7eO_G.js","assets/visuallyHidden-BHQz5bmH.js","assets/cli-command-panel-Do14gxB9.js","assets/check-DPoQ9cBn.js","assets/chevron-down-BgudX6bq.js","assets/copy-B9ICxw_u.js","assets/useValueAsRef-uzS1fuI8.js","assets/useTransitionStatus-Dn-09Anc.js","assets/use-copy-to-clipboard-D-PxomFK.js","assets/trash-BajuWzqn.js","assets/field-iLG94M00.js","assets/useRegisterFieldControl-DkI655of.js","assets/useValueChanged-wii0yYWk.js","assets/switch-CFqwHFZR.js","assets/useAriaLabelledBy-DStQJVTn.js","assets/useControlled-NIWPmeG5.js","assets/textarea-BG1iGvVs.js","assets/page-heading-ZfrnK2wu.js","assets/form-lock-D8Cu0Sfu.js","assets/loader-circle-C7EgoGp_.js","assets/error-banner-Cahg3Ix2.js","assets/index.esm-C31mnnYl.js","assets/zod-nOHMBkds.js","assets/form-fields-CcTBfvl9.js","assets/select-UwOVJofC.js","assets/useScrollLock-iGnmnC-h.js","assets/composite-DysENEse.js","assets/useAnchoredPopupScrollLock-CoHshdlQ.js","assets/isElementDisabled-B7Ri5ug6.js","assets/ToolbarRootContext-iMMgkc2r.js","assets/clamp-D0kAXd-u.js","assets/info-tip-Cxa2GEOd.js","assets/store-BVhNJayb.js","assets/back-link-B8ejy5Fm.js","assets/link-BX-1BPor.js","assets/matchContext-CUAcfcYw.js","assets/tags-field-array-Drjjg7Rv.js"])))=>i.map(i=>d[i]);
|
||||
import{n as e,t}from"./lazyRouteComponent-PMetaA3O.js";import{U as n}from"./fetch-http-handler-C0nfkUzI.js";import{n as r}from"./bedrock-DaOk_4Nm.js";var i=e(`/_auth/bedrock/(guardrails)/list-guardrails/$guardrailId/edit`)({loader:async({context:e,params:t})=>{await e.queryClient.query({...r(t.guardrailId),staleTime:`static`})},head:({params:e})=>({meta:[{title:`Edit ${e.guardrailId} | Guardrail | Mulga`}]}),component:t(()=>n(()=>import(`./_guardrailId.edit-spJljoC2.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54])),`component`)});export{i as t};
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_guardrailId.index-6HLEcbZU.js","assets/schemas-DCQXG11d.js","assets/rolldown-runtime-XKyaHtl9.js","assets/useSuspenseQuery--dusmE-Q.js","assets/queryOptions-Cu2lvOFG.js","assets/QueryClientProvider-BuG40HBe.js","assets/bedrock-DaOk_4Nm.js","assets/aws-client-DrLAG3yq.js","assets/auth-BS4WytAe.js","assets/fetch-http-handler-C0nfkUzI.js","assets/toUtf8.browser-RsrLknAJ.js","assets/index.browser-D7GVEViY.js","assets/guardrail-details-gfN2Qq8A.js","assets/useMutation-lRi0DDcO.js","assets/link-BX-1BPor.js","assets/matchContext-CUAcfcYw.js","assets/useRouter-B_eSHXWd.js","assets/eye-yi4wz0yh.js","assets/visuallyHidden-BHQz5bmH.js","assets/maximize-2-BAlKoEj8.js","assets/pencil-DLzYMCIc.js","assets/sliders-horizontal-DsGbsnBU.js","assets/trash-BajuWzqn.js","assets/useValueAsRef-uzS1fuI8.js","assets/useTransitionStatus-Dn-09Anc.js","assets/field-iLG94M00.js","assets/useRegisterFieldControl-DkI655of.js","assets/useValueChanged-wii0yYWk.js","assets/detail-panel-ukM1Xs6L.js","assets/x-mlA6pp-8.js","assets/info-tip-Cxa2GEOd.js","assets/useScrollLock-iGnmnC-h.js","assets/composite-DysENEse.js","assets/useAnchoredPopupScrollLock-CoHshdlQ.js","assets/isElementDisabled-B7Ri5ug6.js","assets/store-BVhNJayb.js","assets/ToolbarRootContext-iMMgkc2r.js","assets/check-DPoQ9cBn.js","assets/chevron-down-BgudX6bq.js","assets/copy-B9ICxw_u.js","assets/loader-circle-C7EgoGp_.js","assets/collapsible-DddM174D.js","assets/useControlled-NIWPmeG5.js","assets/tabs-9tHEWpNn.js","assets/CompositeRoot-CmHoR-Tk.js","assets/panel-search-WG3n4DSv.js","assets/error-banner-Cahg3Ix2.js","assets/use-copy-to-clipboard-D-PxomFK.js","assets/bedrock-D5w21ls0.js","assets/tags-Ki2a29eH.js","assets/commandBuilder-BGjY-6DN.js","assets/textarea-BG1iGvVs.js","assets/state-badge-CQPRxt3J.js","assets/resource-delete-dialog-D3NYuO-k.js","assets/delete-confirmation-dialog-O7cqOW0Y.js","assets/alert-dialog-D9jG-rkO.js","assets/DialogTitle-nAIOTphJ.js","assets/confirm-phrase-C4Dux-0Y.js"])))=>i.map(i=>d[i]);
|
||||
import{s as e}from"./schemas-DCQXG11d.js";import{n as t,t as n}from"./lazyRouteComponent-PMetaA3O.js";import{t as r}from"./searchMiddleware-Clk50hy1.js";import{U as i}from"./fetch-http-handler-C0nfkUzI.js";import{n as a,r as o}from"./bedrock-DaOk_4Nm.js";import{r as s}from"./panel-search-WG3n4DSv.js";var c=t(`/_auth/bedrock/(guardrails)/list-guardrails/$guardrailId/`)({validateSearch:e({tab:s.tab}),search:{middlewares:[r({tab:`overview`})]},loader:async({context:e,params:t})=>{await Promise.all([e.queryClient.query({...a(t.guardrailId),staleTime:`static`}),e.queryClient.query({...o(t.guardrailId),staleTime:`static`})])},head:({params:e})=>({meta:[{title:`${e.guardrailId} | Guardrail | Mulga`}]}),component:n(()=>i(()=>import(`./_guardrailId.index-6HLEcbZU.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57])),`component`)});export{c as t};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{It as e,Mt as t}from"./schemas-DCQXG11d.js";import{t as n}from"./useSuspenseQuery--dusmE-Q.js";import{n as r}from"./bedrock-DaOk_4Nm.js";import{t as i}from"./guardrail-details-gfN2Qq8A.js";import{t as a}from"./_guardrailId.index-C3IARDfC.js";var o=t(),s=e();function c(){let e=(0,o.c)(14),{guardrailId:t}=a.useParams(),{tab:c}=a.useSearch(),l=a.useNavigate(),u;e[0]===t?u=e[1]:(u=r(t),e[0]=t,e[1]=u);let{data:d}=n(u),f;e[2]!==d.name||e[3]!==d.status||e[4]!==t?(f={id:t,name:d.name,status:d.status},e[2]=d.name,e[3]=d.status,e[4]=t,e[5]=f):f=e[5];let p,m;e[6]===l?(p=e[7],m=e[8]):(p=()=>{l({to:`/bedrock/list-guardrails`})},m=e=>{l({search:{tab:e},replace:!0})},e[6]=l,e[7]=p,e[8]=m);let h;return e[9]!==f||e[10]!==p||e[11]!==m||e[12]!==c?(h=(0,s.jsx)(i,{guardrail:f,layout:`page`,onClose:p,onTabChange:m,tab:c}),e[9]=f,e[10]=p,e[11]=m,e[12]=c,e[13]=h):h=e[13],h}export{c as component};
|
||||
import{It as e,Mt as t}from"./schemas-CjNLFp_1.js";import{t as n}from"./useSuspenseQuery-4EOTujNd.js";import{n as r}from"./bedrock-BpgQsPgj.js";import{t as i}from"./guardrail-details-1Xa-Dr6Y.js";import{t as a}from"./_guardrailId.index-c3ui7tjW.js";var o=t(),s=e();function c(){let e=(0,o.c)(14),{guardrailId:t}=a.useParams(),{tab:c}=a.useSearch(),l=a.useNavigate(),u;e[0]===t?u=e[1]:(u=r(t),e[0]=t,e[1]=u);let{data:d}=n(u),f;e[2]!==d.name||e[3]!==d.status||e[4]!==t?(f={id:t,name:d.name,status:d.status},e[2]=d.name,e[3]=d.status,e[4]=t,e[5]=f):f=e[5];let p,m;e[6]===l?(p=e[7],m=e[8]):(p=()=>{l({to:`/bedrock/list-guardrails`})},m=e=>{l({search:{tab:e},replace:!0})},e[6]=l,e[7]=p,e[8]=m);let h;return e[9]!==f||e[10]!==p||e[11]!==m||e[12]!==c?(h=(0,s.jsx)(i,{guardrail:f,layout:`page`,onClose:p,onTabChange:m,tab:c}),e[9]=f,e[10]=p,e[11]=m,e[12]=c,e[13]=h):h=e[13],h}export{c as component};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_guardrailId.index-Cxox-hbv.js","assets/schemas-CjNLFp_1.js","assets/rolldown-runtime-XKyaHtl9.js","assets/useSuspenseQuery-4EOTujNd.js","assets/queryOptions-BF_U72jK.js","assets/QueryClientProvider-CiUnq0HS.js","assets/bedrock-BpgQsPgj.js","assets/aws-client-CqvC9t1w.js","assets/auth-D8BwZ3UA.js","assets/fetch-http-handler-6B81XedT.js","assets/toUtf8.browser-RsrLknAJ.js","assets/index.browser-D7GVEViY.js","assets/guardrail-details-1Xa-Dr6Y.js","assets/useMutation-CExmKqTG.js","assets/link-BkUlBzrf.js","assets/matchContext-BrTj2Lqx.js","assets/useRouter-BH2du3mx.js","assets/eye-7qRQPg_Z.js","assets/createLucideIcon-BexmpBS_.js","assets/maximize-2-DDRaoY3J.js","assets/pencil-B-yB1qJe.js","assets/sliders-horizontal-BfmfK97o.js","assets/trash-v7j_Lmza.js","assets/useValueAsRef-DY5n0BXa.js","assets/useTransitionStatus-Cyq0ANhR.js","assets/field-D7Rhpj9A.js","assets/useRegisterFieldControl-ByyVMl5k.js","assets/useValueChanged-Cq_W0zj9.js","assets/detail-panel-9quwJD4r.js","assets/x-BT7BjjO9.js","assets/info-tip-BlsL6iHw.js","assets/useScrollLock-ylk6FkWX.js","assets/composite-BSJwpiyu.js","assets/visuallyHidden-CmQ9ItcI.js","assets/useAnchoredPopupScrollLock-D6ZKkcog.js","assets/isElementDisabled-4f1svCb6.js","assets/store-ud_TzzLS.js","assets/ToolbarRootContext-Dx0lufmd.js","assets/check-mxX6T63H.js","assets/chevron-down-CMnru2aW.js","assets/copy-DAui9L5b.js","assets/loader-circle-Daekigmu.js","assets/collapsible-CSbzTZF0.js","assets/useControlled-K3TUf9-y.js","assets/tabs-D3qJM6Sn.js","assets/CompositeRoot-inp2JFFI.js","assets/panel-search-BFfs0Klq.js","assets/error-banner-CoEZZQBV.js","assets/use-copy-to-clipboard-BgG7vE9f.js","assets/bedrock-DReeYQnm.js","assets/tags-CIkbzXUo.js","assets/commandBuilder-Di9LZUIB.js","assets/textarea-BNn1cdVG.js","assets/state-badge-BddzXZo9.js","assets/resource-delete-dialog-CdO3aw07.js","assets/delete-confirmation-dialog-Dx0nBQ7s.js","assets/alert-dialog-B-pr568k.js","assets/DialogTitle-BqK2_8dh.js","assets/confirm-phrase-CsFRFZdW.js"])))=>i.map(i=>d[i]);
|
||||
import{s as e}from"./schemas-CjNLFp_1.js";import{n as t,t as n}from"./lazyRouteComponent-ZWgeB3Vm.js";import{t as r}from"./searchMiddleware-ixtO4F_Y.js";import{U as i}from"./fetch-http-handler-6B81XedT.js";import{n as a,r as o}from"./bedrock-BpgQsPgj.js";import{r as s}from"./panel-search-BFfs0Klq.js";var c=t(`/_auth/bedrock/(guardrails)/list-guardrails/$guardrailId/`)({validateSearch:e({tab:s.tab}),search:{middlewares:[r({tab:`overview`})]},loader:async({context:e,params:t})=>{await Promise.all([e.queryClient.query({...a(t.guardrailId),staleTime:`static`}),e.queryClient.query({...o(t.guardrailId),staleTime:`static`})])},head:({params:e})=>({meta:[{title:`${e.guardrailId} | Guardrail | Mulga`}]}),component:n(()=>i(()=>import(`./_guardrailId.index-Cxox-hbv.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58])),`component`)});export{c as t};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{It as e,Mt as t}from"./schemas-DCQXG11d.js";import{t as n}from"./useSuspenseQuery--dusmE-Q.js";import{_ as r}from"./ec2-DcwXjFvB.js";import{t as i}from"./_id-Cq4i8enS.js";import{t as a}from"./back-link-B8ejy5Fm.js";import{t as o}from"./nat-gateway-details-Bsu4w14B.js";var s=t(),c=e();function l(){let e=(0,s.c)(11),{id:t}=i.useParams(),{tab:l}=i.useSearch(),u=i.useNavigate(),d;e[0]===t?d=e[1]:(d=r(t),e[0]=t,e[1]=d);let{data:f}=n(d),p=f.NatGateways?.[0];if(!p?.NatGatewayId){let t;return e[2]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,c.jsxs)(c.Fragment,{children:[(0,c.jsx)(a,{to:`/ec2/describe-nat-gateways`,children:`Back to NAT gateways`}),(0,c.jsx)(`p`,{className:`text-muted-foreground`,children:`NAT gateway not found.`})]}),e[2]=t):t=e[2],t}let m,h;e[3]===u?(m=e[4],h=e[5]):(m=()=>{u({to:`/ec2/describe-nat-gateways`})},h=e=>{u({search:{tab:e},replace:!0})},e[3]=u,e[4]=m,e[5]=h);let g;return e[6]!==p||e[7]!==m||e[8]!==h||e[9]!==l?(g=(0,c.jsx)(o,{layout:`page`,nat:p,onClose:m,onTabChange:h,tab:l}),e[6]=p,e[7]=m,e[8]=h,e[9]=l,e[10]=g):g=e[10],g}export{l as component};
|
||||
import{It as e,Mt as t}from"./schemas-CjNLFp_1.js";import{t as n}from"./useSuspenseQuery-4EOTujNd.js";import{_ as r}from"./ec2-CmUP7Xg7.js";import{t as i}from"./_id-CfXt0blV.js";import{t as a}from"./back-link-CAMvxfHu.js";import{t as o}from"./nat-gateway-details-a7A4uQc2.js";var s=t(),c=e();function l(){let e=(0,s.c)(11),{id:t}=i.useParams(),{tab:l}=i.useSearch(),u=i.useNavigate(),d;e[0]===t?d=e[1]:(d=r(t),e[0]=t,e[1]=d);let{data:f}=n(d),p=f.NatGateways?.[0];if(!p?.NatGatewayId){let t;return e[2]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,c.jsxs)(c.Fragment,{children:[(0,c.jsx)(a,{to:`/ec2/describe-nat-gateways`,children:`Back to NAT gateways`}),(0,c.jsx)(`p`,{className:`text-muted-foreground`,children:`NAT gateway not found.`})]}),e[2]=t):t=e[2],t}let m,h;e[3]===u?(m=e[4],h=e[5]):(m=()=>{u({to:`/ec2/describe-nat-gateways`})},h=e=>{u({search:{tab:e},replace:!0})},e[3]=u,e[4]=m,e[5]=h);let g;return e[6]!==p||e[7]!==m||e[8]!==h||e[9]!==l?(g=(0,c.jsx)(o,{layout:`page`,nat:p,onClose:m,onTabChange:h,tab:l}),e[6]=p,e[7]=m,e[8]=h,e[9]=l,e[10]=g):g=e[10],g}export{l as component};
|
||||
@@ -1,2 +0,0 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_id-CUA1INs8.js","assets/schemas-DCQXG11d.js","assets/rolldown-runtime-XKyaHtl9.js","assets/useSuspenseQuery--dusmE-Q.js","assets/queryOptions-Cu2lvOFG.js","assets/QueryClientProvider-BuG40HBe.js","assets/ec2-DcwXjFvB.js","assets/aws-client-DrLAG3yq.js","assets/auth-BS4WytAe.js","assets/fetch-http-handler-C0nfkUzI.js","assets/toUtf8.browser-RsrLknAJ.js","assets/index.browser-D7GVEViY.js","assets/commandBuilder-M7C_FWzS.js","assets/back-link-B8ejy5Fm.js","assets/link-BX-1BPor.js","assets/matchContext-CUAcfcYw.js","assets/useRouter-B_eSHXWd.js","assets/info-tip-Cxa2GEOd.js","assets/visuallyHidden-BHQz5bmH.js","assets/useScrollLock-iGnmnC-h.js","assets/useValueAsRef-uzS1fuI8.js","assets/useTransitionStatus-Dn-09Anc.js","assets/composite-DysENEse.js","assets/useAnchoredPopupScrollLock-CoHshdlQ.js","assets/useValueChanged-wii0yYWk.js","assets/isElementDisabled-B7Ri5ug6.js","assets/store-BVhNJayb.js","assets/ToolbarRootContext-iMMgkc2r.js","assets/vpc-details-DaK9VtMD.js","assets/useQuery-D6TojLgI.js","assets/eye-yi4wz0yh.js","assets/maximize-2-BAlKoEj8.js","assets/sliders-horizontal-DsGbsnBU.js","assets/trash-BajuWzqn.js","assets/detail-panel-ukM1Xs6L.js","assets/x-mlA6pp-8.js","assets/check-DPoQ9cBn.js","assets/chevron-down-BgudX6bq.js","assets/copy-B9ICxw_u.js","assets/loader-circle-C7EgoGp_.js","assets/collapsible-DddM174D.js","assets/useControlled-NIWPmeG5.js","assets/tabs-9tHEWpNn.js","assets/CompositeRoot-CmHoR-Tk.js","assets/panel-search-WG3n4DSv.js","assets/error-banner-Cahg3Ix2.js","assets/use-copy-to-clipboard-D-PxomFK.js","assets/ec2-mbMOQDVK.js","assets/useMutation-lRi0DDcO.js","assets/subnet-calculator-DDrAb5xp.js","assets/tags-Ki2a29eH.js","assets/state-badge-CQPRxt3J.js","assets/resource-tags-mPu3NXlt.js","assets/tags-CXDQrv7y.js","assets/commandBuilder-zZSrq900.js","assets/commandBuilder-CTdNRogs.js","assets/commandBuilder-lcvi73e2.js","assets/inline-tags-BKqbcCST.js","assets/pencil-DLzYMCIc.js","assets/plus-BK_7eO_G.js","assets/field-iLG94M00.js","assets/useRegisterFieldControl-DkI655of.js","assets/resource-delete-dialog-D3NYuO-k.js","assets/delete-confirmation-dialog-O7cqOW0Y.js","assets/alert-dialog-D9jG-rkO.js","assets/DialogTitle-nAIOTphJ.js","assets/confirm-phrase-C4Dux-0Y.js"])))=>i.map(i=>d[i]);
|
||||
import{s as e}from"./schemas-DCQXG11d.js";import{n as t,t as n}from"./lazyRouteComponent-PMetaA3O.js";import{t as r}from"./searchMiddleware-Clk50hy1.js";import{U as i}from"./fetch-http-handler-C0nfkUzI.js";import{M as a,k as o}from"./ec2-DcwXjFvB.js";import{r as s}from"./panel-search-WG3n4DSv.js";var c=t(`/_auth/ec2/(vpc)/describe-vpcs/$id`)({validateSearch:e({tab:s.tab}),search:{middlewares:[r({tab:`overview`})]},loader:async({context:e,params:t})=>{await Promise.all([e.queryClient.query({...a(t.id),staleTime:`static`}),e.queryClient.query({...o,staleTime:`static`})])},head:({params:e})=>({meta:[{title:`${e.id} | VPC | Mulga`}]}),component:n(()=>i(()=>import(`./_id-CUA1INs8.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66])),`component`)});export{c as t};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user