diff --git a/.github/workflows/skywalking.yaml b/.github/workflows/skywalking.yaml index 504dc19a7f80..05adfffa9e65 100644 --- a/.github/workflows/skywalking.yaml +++ b/.github/workflows/skywalking.yaml @@ -399,8 +399,9 @@ jobs: env: ES_VERSION=8.18.8 - name: Runtime Rule LAL Hot-Update config: test/e2e-v2/cases/runtime-rule/lal/e2e.yaml - - name: Runtime Rule Cluster Convergence + - name: Runtime Rule Cluster (kind) config: test/e2e-v2/cases/runtime-rule/cluster/e2e.yaml + runs-on: ubuntu-24.04 - name: DSL Debug API — MAL config: test/e2e-v2/cases/dsl-debugging/mal/e2e.yaml - name: DSL Debug API — OAL diff --git a/docs/en/changes/changes.md b/docs/en/changes/changes.md index bffa0e8405e4..51aa0dfa7956 100644 --- a/docs/en/changes/changes.md +++ b/docs/en/changes/changes.md @@ -248,6 +248,8 @@ refcount-tracked and unregistered when the last declaring rule is removed. See [runtime-rule-hot-update.md#dynamic-layers](../concepts-and-designs/runtime-rule-hot-update.md) for the conflict rules and limitations. +* Fix: runtime-rule (MAL/LAL hot-update) schema changes now work in `no-init` mode — the deployment mode every production cluster runs. Previously a runtime `addOrUpdate` that introduced a new metric blocked forever in the storage installer's init-node poll loop (`ModelInstaller.whenCreating`) on a `no-init` OAP, because the gate keyed off `RunningMode` rather than the operation's intent; the `/delete?mode=revertToBundled` recreate and BanyanDB in-place shape updates were dead the same way. The poll loop is now gated on a new `StorageManipulationOpt.Flags.deferDDLToInitNode` bit set only on the static boot-time `schemaCreateIfAbsent()` opt (DRYed into `ModelInstaller.deferDDLToInitNode(opt)` and reused by the BanyanDB shape-check / group-DDL gates), so the runtime-rule opts (`withSchemaChange` / `verifySchemaOnly` / `withoutSchemaChange`) are driven by their flags and by cluster main-ness — `no-init` and `default` no longer differ for DSL DDL; `init` mode stays the dedicated initializer. `DSLManager.tickStorageOpt` is collapsed accordingly (main → `withSchemaChange`, peer → `verifySchemaOnly` at boot / `withoutSchemaChange` on tick). +* Fix: runtime-rule cross-node writes no longer fail with `HTTP 400 forward_self_loop` on a multi-replica Kubernetes cluster. Every OAP replica shared the cluster `selfNodeId` `0.0.0.0_11800` (derived from the `0.0.0.0` agent gRPC bind host via `TelemetryRelatedContext`), so the main's self-loop guard rejected a legitimate peer-to-peer Forward as if it had looped back. The runtime-rule node identity now prefers the unique per-pod `SKYWALKING_COLLECTOR_UID` (the pod UID injected by the helm chart / swck operator from `metadata.uid`), resolved in `start()` before any apply, and falls back to the telemetry id for non-k8s deployments. Adds a kind-based no-init cluster e2e (`test/e2e-v2/cases/runtime-rule/cluster`, deployed via skywalking-helm with `oap.replicas=2`) that drives the apply / STRUCTURAL / inactivate / delete lifecycle and the cross-node Forward path, replacing the prior docker-compose default-mode cluster case. * Fix: remove the redundant tags from the `envoy-ai-gateway.yaml` LAL configuration. * Add Zipkin Virtual GenAI e2e test. Use `zipkin_json` exporter to avoid protobuf dependency conflict between `opentelemetry-exporter-zipkin-proto-http` (protobuf~=3.12) and `opentelemetry-proto` (protobuf>=5.0). diff --git a/oap-server/server-admin/runtime-rule/src/main/java/org/apache/skywalking/oap/server/receiver/runtimerule/module/RuntimeRuleModuleProvider.java b/oap-server/server-admin/runtime-rule/src/main/java/org/apache/skywalking/oap/server/receiver/runtimerule/module/RuntimeRuleModuleProvider.java index f75d77051289..ec7197d5de63 100644 --- a/oap-server/server-admin/runtime-rule/src/main/java/org/apache/skywalking/oap/server/receiver/runtimerule/module/RuntimeRuleModuleProvider.java +++ b/oap-server/server-admin/runtime-rule/src/main/java/org/apache/skywalking/oap/server/receiver/runtimerule/module/RuntimeRuleModuleProvider.java @@ -219,6 +219,15 @@ public class RuntimeRuleModuleProvider extends ModuleProvider { */ private static final long SCHEDULER_INITIAL_DELAY_SECONDS = 2L; + /** + * Env var carrying this OAP's unique per-node identity — the Kubernetes pod UID, injected + * by the skywalking-helm chart / swck operator from {@code metadata.uid}. Used as the + * runtime-rule cluster {@code selfNodeId} when present, because the telemetry-id fallback + * (gRPC {@code host_port}) collides across replicas under k8s where the bind host is + * {@code 0.0.0.0} (every pod reports {@code 0.0.0.0_11800}). + */ + private static final String COLLECTOR_UID_ENV = "SKYWALKING_COLLECTOR_UID"; + private RuntimeRuleModuleConfig moduleConfig; private ScheduledExecutorService reconcilerExecutor; private DSLManager dslManager; @@ -272,7 +281,12 @@ public void start() throws ServiceNotProvidedException, ModuleStartException { // cluster gRPC bus (default 11800). Privileged admin RPCs stay on the // admin-only port (default 17129) so a compromised node on the agent // network cannot reach Suspend/Resume/Forward. - final String selfNodeId = TelemetryRelatedContext.INSTANCE.getId(); + // Resolve this node's stable, unique cluster identity HERE in start() — before + // notifyAfterCompleted() applies any rule — so the node knows who it is before it + // forwards a write to the main or broadcasts Suspend/Resume. Must be unique per + // replica: it is the Forward/Suspend/Resume sender id and the key the receiver's + // self-loop guard compares against. See resolveSelfNodeId(). + final String selfNodeId = resolveSelfNodeId(); final AdminClusterChannelManager adminPeerChannels = getManager().find(AdminServerModule.NAME).provider() .getService(AdminClusterChannelManager.class); @@ -343,24 +357,25 @@ public void notifyAfterCompleted() throws ModuleStartException { // applies under {@code withSchemaChange} if this node resolves as main. Backend DDL is // idempotent so the re-apply costs nothing. try { - // atBoot=true so a no-init OAP picks verifySchemaOnly and refuses to - // start with a missing or shape-mismatched backend (k8s pod backloop) + // atBoot=true so a cluster peer picks verifySchemaOnly and refuses to + // start against a missing or shape-mismatched backend (k8s pod backloop) // instead of silently registering local workers against schema that - // doesn't exist. Init / default-mode OAPs are unaffected — their boot - // opt mirrors the standard tick choice for those modes. + // doesn't exist; the main picks withSchemaChange and re-creates missing + // runtime schema. The choice is by cluster main-ness, not running mode + // (see DSLManager.tickStorageOpt); init mode is the lone exception. dslManager.tick(true); log.info("Runtime rule dslManager: synchronous first tick completed " + "(runtime-only DB rows are now applied locally)."); } catch (final RuntimeException re) { - // Boot pass under verifySchemaOnly re-throws missing/mismatch as a - // RuntimeException so module bootstrap aborts. Translate to - // ModuleStartException so the OAP exit message points the operator at - // the right place. + // The boot pass re-throws as a RuntimeException so module bootstrap aborts — + // a peer's verifySchemaOnly hitting a missing/mismatched backend, or a main's + // withSchemaChange failing to create it. Translate to ModuleStartException so + // the OAP exit message points the operator at the right place. throw new ModuleStartException( - "Runtime rule dslManager boot pass failed under verifySchemaOnly; " - + "the backend schema is missing or diverges from the declared rule. " - + "Bring up the init OAP first or align rule files with the backend, " - + "then restart this node.", + "Runtime rule dslManager boot pass failed: backend schema is missing, " + + "diverges from the declared rule, or could not be created. On a peer, " + + "bring up the cluster main (or init OAP) first; on the main, align the " + + "rule files with the backend, then restart this node.", re); } catch (final Throwable t) { log.warn("Runtime rule dslManager: synchronous first tick failed — " @@ -393,6 +408,32 @@ public void notifyAfterCompleted() throws ModuleStartException { SCHEDULER_INITIAL_DELAY_SECONDS, intervalSeconds); } + /** + * Resolve this node's unique, stable runtime-rule cluster identity. Prefers the Kubernetes + * pod UID ({@value #COLLECTOR_UID_ENV}, injected by the helm chart / swck operator from + * {@code metadata.uid}) because it is unique per replica; falls back to the telemetry id + * ({@code host_port}) for non-k8s deployments where each node already has a distinct host. + * + *
Why not the telemetry id directly: under Kubernetes the agent gRPC bind host is + * {@code 0.0.0.0}, so every replica's telemetry id is {@code 0.0.0.0_11800} — identical. + * That collision makes the receiver's self-loop guard (sender id == own id) reject a + * legitimate peer-to-peer Forward as if it had looped back, breaking cross-node writes on + * any multi-replica k8s cluster. {@code MainRouter} already routes correctly off the + * cluster peer addresses (pod IPs); only the self-identity used for loop suppression needs + * to be unique, which the pod UID guarantees. + */ + private String resolveSelfNodeId() { + final String collectorUid = System.getenv(COLLECTOR_UID_ENV); + if (collectorUid != null && !collectorUid.trim().isEmpty()) { + log.info("Runtime rule: selfNodeId from {} (pod UID) = {}", COLLECTOR_UID_ENV, collectorUid); + return collectorUid; + } + final String telemetryId = TelemetryRelatedContext.INSTANCE.getId(); + log.info("Runtime rule: {} not set; selfNodeId falls back to telemetry id = {} " + + "(ensure it is unique per node in a multi-node cluster).", COLLECTOR_UID_ENV, telemetryId); + return telemetryId; + } + @Override public String[] requiredModules() { return new String[] { diff --git a/oap-server/server-admin/runtime-rule/src/main/java/org/apache/skywalking/oap/server/receiver/runtimerule/reconcile/DSLManager.java b/oap-server/server-admin/runtime-rule/src/main/java/org/apache/skywalking/oap/server/receiver/runtimerule/reconcile/DSLManager.java index bd46ae69ed3d..102a05ebbe85 100644 --- a/oap-server/server-admin/runtime-rule/src/main/java/org/apache/skywalking/oap/server/receiver/runtimerule/reconcile/DSLManager.java +++ b/oap-server/server-admin/runtime-rule/src/main/java/org/apache/skywalking/oap/server/receiver/runtimerule/reconcile/DSLManager.java @@ -229,15 +229,17 @@ public void tick() { /** * Variant invoked once at boot from {@code RuntimeRuleModuleProvider.notifyAfterCompleted} - * with {@code atBoot=true}. The boot pass on a no-init OAP picks + * with {@code atBoot=true}. The boot pass on a cluster peer picks * {@link StorageManipulationOpt#verifySchemaOnly()} so missing or shape-mismatched * backend schema fails the bootstrap (k8s pod backloop) instead of silently - * proceeding. The scheduled executor calls the no-arg overload so subsequent ticks - * stay on the lenient {@code withoutSchemaChange} retry path. + * proceeding; the main picks {@link StorageManipulationOpt#withSchemaChange()} + * so it re-creates any missing runtime schema. The scheduled executor calls the no-arg + * overload so subsequent peer ticks stay on the lenient {@code withoutSchemaChange} + * retry path. * - *
Boot semantics are scoped to no-init mode only — init-mode OAPs continue to - * pick {@link StorageManipulationOpt#schemaCreateIfAbsent()} (boot creates), and - * default-mode OAPs continue to pick by cluster main-ness. + *
The choice is by cluster main-ness, not running mode — no-init and default behave
+ * identically (see {@link #tickStorageOpt}). Init mode is the one exception: the
+ * dedicated initialiser picks {@link StorageManipulationOpt#schemaCreateIfAbsent()}.
*/
public void tick(final boolean atBoot) {
try {
@@ -708,44 +710,38 @@ private void invokeAlarmReset(final Set Two axes:
+ * For runtime-rule (DSL) DDL the only axis that matters is cluster main-ness —
+ * not the init / no-init / default running mode. The running-mode axis governs
+ * static schema (the init OAP creates it, no-init OAPs wait); a runtime rule is
+ * created at runtime and the init OAP never knows about it, so gating DSL DDL on running
+ * mode would leave every production (no-init) cluster unable to apply rules. no-init and
+ * default therefore behave identically here.
*
- * RunningMode (boot/init context).
+ * init mode — the one exception. The dedicated initialiser picks
+ * {@link StorageManipulationOpt#schemaCreateIfAbsent()}, matching the static-rule install
+ * path (create-if-absent, idempotent against a backend that already holds the resource).
+ *
+ * Everything else (no-init or default) — branch on main-ness:
* Cluster main-ness (default mode only).
- * When the cluster module isn't wired (embedded test topology), {@link
- * MainRouter#isSelfMain} returns {@code true} and the default-mode branch falls
- * through to {@code withSchemaChange} — single-process deployments are always main.
+ * When the cluster module isn't wired (embedded / single-process topology),
+ * {@link MainRouter#isSelfMain} returns {@code true} so we fall through to
+ * {@code withSchemaChange} — a single process is always its own main.
*
* @param atBoot true for the synchronous one-shot pass invoked from
* {@code RuntimeRuleModuleProvider.notifyAfterCompleted}; false for
@@ -755,20 +751,21 @@ private StorageManipulationOpt tickStorageOpt(final boolean atBoot) {
if (RunningMode.isInitMode()) {
return StorageManipulationOpt.schemaCreateIfAbsent();
}
- if (RunningMode.isNoInitMode()) {
- return atBoot
- ? StorageManipulationOpt.verifySchemaOnly()
- : StorageManipulationOpt.withoutSchemaChange();
- }
+ final boolean selfMain;
try {
final AdminClusterChannelManager apm =
moduleManager.find(AdminServerModule.NAME).provider()
.getService(AdminClusterChannelManager.class);
- return MainRouter.isSelfMain(apm)
- ? StorageManipulationOpt.withSchemaChange()
- : StorageManipulationOpt.withoutSchemaChange();
+ selfMain = MainRouter.isSelfMain(apm);
} catch (final Throwable t) {
+ // Cluster module not wired (embedded / single-process) — always main.
+ return StorageManipulationOpt.withSchemaChange();
+ }
+ if (selfMain) {
return StorageManipulationOpt.withSchemaChange();
}
+ return atBoot
+ ? StorageManipulationOpt.verifySchemaOnly()
+ : StorageManipulationOpt.withoutSchemaChange();
}
}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/model/ModelInstaller.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/model/ModelInstaller.java
index 735e7b379dc2..d65d7fb75bc1 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/model/ModelInstaller.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/model/ModelInstaller.java
@@ -89,10 +89,15 @@ public void whenCreating(Model model, StorageManipulationOpt opt) throws Storage
return;
}
- // Legacy poll loop for non-init OAPs that did not opt into the strict verify
- // mode. Static models (boot-time) still take this path; runtime-rule reconciler
- // explicitly chooses verify so this loop is bypassed.
- if (RunningMode.isNoInitMode()) {
+ // Poll loop for the STATIC boot-time path on a non-init OAP: the init OAP owns
+ // schema creation, so this node waits until the resource appears rather than
+ // creating it. Gated on deferDDLToInitNode (set only on SCHEMA_CREATE_IF_ABSENT),
+ // NOT on RunningMode alone — a runtime-rule DSL apply (withSchemaChange) is the
+ // operator/main-driven authority and must fall through to createTable below
+ // regardless of no-init, because no init OAP knows about a metric created at
+ // runtime. Without this, a no-init OAP would block here forever waiting for a
+ // resource that only this very apply would ever create.
+ if (deferDDLToInitNode(opt)) {
while (true) {
InstallInfo info = isExists(model, opt);
if (!info.isAllExist()) {
@@ -148,6 +153,23 @@ public void whenRemoving(Model model, StorageManipulationOpt opt) throws Storage
StorageManipulationOpt.Outcome.DROPPED, null);
}
+ /**
+ * True when this manipulation must defer all backend DDL to the dedicated init OAP and
+ * wait for it, rather than create / update / reshape the resource on this node. This is
+ * the single source of truth for the "no-init OAP doesn't own schema" rule across the
+ * base installer and every backend subclass — call it instead of re-checking
+ * {@link RunningMode#isNoInitMode()} inline, so the rule stays one decision.
+ *
+ * True only for the static boot-time {@link StorageManipulationOpt#schemaCreateIfAbsent()}
+ * opt on a {@code no-init} OAP. The runtime-rule (DSL) opts leave
+ * {@link StorageManipulationOpt.Flags#isDeferDDLToInitNode() deferDDLToInitNode} unset, so
+ * an operator-driven apply is governed by the opt's own create / update / drop flags and
+ * by cluster main-ness — never by the init / no-init / default running mode.
+ */
+ protected static boolean deferDDLToInitNode(final StorageManipulationOpt opt) {
+ return RunningMode.isNoInitMode() && opt.getFlags().isDeferDDLToInitNode();
+ }
+
public void start() {
}
diff --git a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/model/StorageManipulationOpt.java b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/model/StorageManipulationOpt.java
index 3fe2e66eab50..9b6d8cb04a3e 100644
--- a/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/model/StorageManipulationOpt.java
+++ b/oap-server/server-core/src/main/java/org/apache/skywalking/oap/server/core/storage/model/StorageManipulationOpt.java
@@ -73,10 +73,11 @@
* Callers:
* Backend behaviour: read-only inspection. The installer issues the same metadata
* read RPCs as {@link Mode#SCHEMA_CREATE_IF_ABSENT} but never invokes create / update / drop. On
@@ -137,15 +138,22 @@ public enum Mode {
.escalateToCaller(true)
.build()),
/**
- * Static boot path on an init-mode OAP. Installer creates absent resources, but
- * if a resource already exists with a shape that diverges from the declared
- * model it records {@link Outcome#SKIPPED_SHAPE_MISMATCH} and does not
- * call update / reshape. Operator must reconcile via the runtime-rule REST
- * endpoint — boot is not allowed to silently mutate backend shape.
+ * Static boot-time model registration, run by every OAP. On an init / standalone
+ * OAP the installer creates absent resources, but if a resource already exists with
+ * a shape that diverges from the declared model it records
+ * {@link Outcome#SKIPPED_SHAPE_MISMATCH} and does not call
+ * update / reshape. Operator must reconcile via the runtime-rule REST endpoint —
+ * boot is not allowed to silently mutate backend shape.
+ *
+ * This is the only mode that sets {@code deferDDLToInitNode}: on a {@code no-init}
+ * OAP the installer defers to the init OAP (waits in the
+ * {@link ModelInstaller#whenCreating} poll loop) rather than creating the resource
+ * itself. The runtime-rule (DSL) modes never defer.
*/
SCHEMA_CREATE_IF_ABSENT(Flags.builder()
.inspectBackend(true)
.createMissing(true)
+ .deferDDLToInitNode(true)
.build()),
/**
* Boot path on a non-init OAP. Installer issues the same read-only inspection
@@ -247,6 +255,22 @@ public static final class Flags {
* the node.
*/
private final boolean escalateToCaller;
+ /**
+ * On a {@code no-init} OAP, defer all backend DDL to the dedicated init OAP and wait
+ * (poll loop in {@link ModelInstaller#whenCreating}) rather than create / update the
+ * resource here. Set ONLY on {@link Mode#SCHEMA_CREATE_IF_ABSENT} — the static
+ * boot-time model registration that every OAP runs. The init / no-init / default
+ * running-mode axis governs static schema only.
+ *
+ * The runtime-rule (DSL) opts — {@link Mode#WITH_SCHEMA_CHANGE},
+ * {@link Mode#VERIFY_SCHEMA_ONLY}, {@link Mode#WITHOUT_SCHEMA_CHANGE} — leave this
+ * {@code false}, so an operator-driven runtime apply is driven by the other flags and
+ * by cluster main-ness, never by {@code RunningMode}. Without this distinction a
+ * no-init OAP (every production cluster node) would route a runtime {@code withSchemaChange}
+ * create into the init-node poll loop and block forever, because no init OAP knows
+ * about a metric that was created at runtime.
+ */
+ private final boolean deferDDLToInitNode;
}
@Getter
diff --git a/oap-server/server-core/src/test/java/org/apache/skywalking/oap/server/core/storage/model/ModelInstallerNoInitTest.java b/oap-server/server-core/src/test/java/org/apache/skywalking/oap/server/core/storage/model/ModelInstallerNoInitTest.java
new file mode 100644
index 000000000000..d9cda58cd7ae
--- /dev/null
+++ b/oap-server/server-core/src/test/java/org/apache/skywalking/oap/server/core/storage/model/ModelInstallerNoInitTest.java
@@ -0,0 +1,140 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.skywalking.oap.server.core.storage.model;
+
+import java.time.Duration;
+import org.apache.skywalking.oap.server.core.RunningMode;
+import org.apache.skywalking.oap.server.core.storage.StorageException;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Regression guard for the runtime-rule (DSL) schema-change path on a {@code no-init} OAP —
+ * every production cluster node runs no-init. The base {@link ModelInstaller#whenCreating}
+ * poll loop must defer to the init OAP only for the static boot-time opt
+ * ({@link StorageManipulationOpt#schemaCreateIfAbsent()}); a runtime-rule
+ * {@link StorageManipulationOpt#withSchemaChange()} apply must fall through to
+ * {@code createTable} and create the resource itself, because no init OAP knows about a
+ * metric created at runtime. Before the {@code deferDDLToInitNode} flag, a no-init OAP
+ * routed the runtime create into the poll loop and blocked forever.
+ */
+class ModelInstallerNoInitTest {
+
+ @AfterEach
+ void resetRunningMode() {
+ // RunningMode is a process-wide static; setMode("") is a no-op, so reset to a
+ // neutral non-init/non-no-init value to avoid leaking no-init into other tests.
+ RunningMode.setMode("default");
+ }
+
+ @Test
+ void deferFlagSetOnlyOnStaticBootOpt() {
+ assertTrue(StorageManipulationOpt.schemaCreateIfAbsent().getFlags().isDeferDDLToInitNode(),
+ "static boot opt must defer DDL to the init node");
+ assertFalse(StorageManipulationOpt.withSchemaChange().getFlags().isDeferDDLToInitNode(),
+ "runtime-rule withSchemaChange must NOT defer — it is the DDL authority");
+ assertFalse(StorageManipulationOpt.verifySchemaOnly().getFlags().isDeferDDLToInitNode());
+ assertFalse(StorageManipulationOpt.withoutSchemaChange().getFlags().isDeferDDLToInitNode());
+ }
+
+ @Test
+ void noInitMainCreatesNewMetricUnderWithSchemaChange() {
+ RunningMode.setMode("no-init");
+ final RecordingInstaller installer = new RecordingInstaller(false /* resource absent */);
+ final Model model = mock(Model.class);
+ when(model.getName()).thenReturn("runtime_metric");
+
+ // Must return (not spin in the no-init poll loop) and must create the resource. The
+ // preemptive timeout turns a regression — the historical infinite wait — into a fast
+ // failure instead of a hung build.
+ assertTimeoutPreemptively(Duration.ofSeconds(10), () ->
+ installer.whenCreating(model, StorageManipulationOpt.withSchemaChange()));
+ assertEquals(1, installer.createTableCalls,
+ "runtime withSchemaChange on a no-init OAP must create the new resource");
+ }
+
+ @Test
+ void noInitStaticBootDefersToInitNode() throws StorageException {
+ RunningMode.setMode("no-init");
+ // Resource already present so the defer poll loop breaks on its first probe instead
+ // of waiting forever — lets the test assert the defer path without hanging.
+ final RecordingInstaller installer = new RecordingInstaller(true /* resource present */);
+ final Model model = mock(Model.class);
+ when(model.getName()).thenReturn("static_metric");
+
+ installer.whenCreating(model, StorageManipulationOpt.schemaCreateIfAbsent());
+ assertEquals(0, installer.createTableCalls,
+ "static boot on a no-init OAP must defer to the init node, never create");
+ }
+
+ @Test
+ void withSchemaChangeSkipsCreateWhenResourceAlreadyExists() throws StorageException {
+ RunningMode.setMode("no-init");
+ final RecordingInstaller installer = new RecordingInstaller(true /* resource present */);
+ final Model model = mock(Model.class);
+ when(model.getName()).thenReturn("existing_metric");
+
+ installer.whenCreating(model, StorageManipulationOpt.withSchemaChange());
+ assertEquals(0, installer.createTableCalls,
+ "withSchemaChange must not re-create a resource that already exists");
+ }
+
+ /** Minimal concrete {@link ModelInstaller} that records createTable calls and reports a
+ * fixed existence result, so the base whenCreating branching can be exercised without a
+ * real storage backend. */
+ private static final class RecordingInstaller extends ModelInstaller {
+ private final boolean resourcePresent;
+ private int createTableCalls;
+
+ private RecordingInstaller(final boolean resourcePresent) {
+ super(null, null);
+ this.resourcePresent = resourcePresent;
+ }
+
+ @Override
+ public InstallInfo isExists(final Model model, final StorageManipulationOpt opt) {
+ final TestInstallInfo info = new TestInstallInfo(model);
+ info.setAllExist(resourcePresent);
+ return info;
+ }
+
+ @Override
+ public void createTable(final Model model) {
+ createTableCalls++;
+ }
+ }
+
+ private static final class TestInstallInfo extends ModelInstaller.InstallInfo {
+ private TestInstallInfo(final Model model) {
+ super(model);
+ }
+
+ @Override
+ public String buildInstallInfoMsg() {
+ return "test";
+ }
+ }
+}
diff --git a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBIndexInstaller.java b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBIndexInstaller.java
index 47ceac8df3b3..cabfb75276cd 100644
--- a/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBIndexInstaller.java
+++ b/oap-server/server-storage-plugin/storage-banyandb-plugin/src/main/java/org/apache/skywalking/oap/server/storage/plugin/banyandb/BanyanDBIndexInstaller.java
@@ -144,11 +144,13 @@ public InstallInfo isExists(Model model, StorageManipulationOpt opt) throws Stor
installInfo.setAllExist(false);
return installInfo;
} else {
- // Run shape-compat checks unless we're in the legacy no-init poll loop
- // path. failOnAbsence implies the caller wants strict verification even
- // in non-init mode (VERIFY_SCHEMA_ONLY), so honour that instead of just
- // gating on RunningMode.
- final boolean runShapeChecks = !RunningMode.isNoInitMode() || opt.getFlags().isFailOnAbsence();
+ // Run shape-compat checks — and the updates they drive for withSchemaChange —
+ // unless this is the static boot-time path deferring to the init OAP. The
+ // runtime-rule DSL opts (withSchemaChange / verifySchemaOnly) are never
+ // deferred, so an operator-driven shape UPDATE reconciles on a no-init OAP
+ // exactly as on a default / standalone one. (verifySchemaOnly still runs the
+ // checks but records SKIPPED_SHAPE_MISMATCH instead of writing.)
+ final boolean runShapeChecks = !deferDDLToInitNode(opt);
if (model.isTimeSeries()) {
// register models only locally(Schema cache) but not remotely
if (model.isRecord()) {
@@ -637,10 +639,15 @@ private ResourceExist checkResourceExistence(MetadataRegistry.SchemaMetadata met
optsBuilder.addAllDefaultStages(metadata.getResource().getDefaultQueryStages());
}
gBuilder.setResourceOpts(optsBuilder.build());
- if (!RunningMode.isNoInitMode()) {
- if (!groupAligned.contains(metadata.getGroup())) {
+ // Group DDL follows the opt, not RunningMode: a runtime-rule withSchemaChange
+ // creates / updates the group on whatever node reaches here (peers short-circuit
+ // earlier via inspectBackend=false), while the static boot path defers to the init
+ // OAP on no-init. Create is gated on createMissing and update on !failOnShapeMismatch
+ // so verifySchemaOnly stays read-only even though it is not deferred.
+ if (!deferDDLToInitNode(opt) && !groupAligned.contains(metadata.getGroup())) {
+ if (!resourceExist.isHasGroup()) {
// create the group if not exist
- if (!resourceExist.isHasGroup()) {
+ if (opt.getFlags().isCreateMissing()) {
try {
Group g = client.define(gBuilder.build());
if (g != null) {
@@ -653,16 +660,16 @@ private ResourceExist checkResourceExistence(MetadataRegistry.SchemaMetadata met
throw ex;
}
}
- } else {
- // update the group if necessary
- if (this.checkGroup(metadata, client)) {
- opt.recordModRevision(client.update(gBuilder.build()));
- log.info("group {} updated", metadata.getGroup());
- }
}
- // mark the group as aligned
- groupAligned.add(metadata.getGroup());
+ } else {
+ // update the group if necessary
+ if (!opt.getFlags().isFailOnShapeMismatch() && this.checkGroup(metadata, client)) {
+ opt.recordModRevision(client.update(gBuilder.build()));
+ log.info("group {} updated", metadata.getGroup());
+ }
}
+ // mark the group as aligned
+ groupAligned.add(metadata.getGroup());
}
return resourceExist;
}
diff --git a/test/e2e-v2/cases/runtime-rule/cluster/cluster-flow.sh b/test/e2e-v2/cases/runtime-rule/cluster/cluster-flow.sh
index ac371559cbda..0740a9947c6b 100755
--- a/test/e2e-v2/cases/runtime-rule/cluster/cluster-flow.sh
+++ b/test/e2e-v2/cases/runtime-rule/cluster/cluster-flow.sh
@@ -15,17 +15,26 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-# Drives a runtime-rule apply on OAP-1 and asserts OAP-2 converges on the same
-# (catalog, name, contentHash) within the reconciler tick window. Run from the
-# repo root.
+# Runtime-rule lifecycle + cross-node convergence on a Kubernetes (kind) cluster
+# deployed in NO-INIT mode — the topology every production SkyWalking cluster runs
+# (a one-shot `-Dmode=init` Job creates static schema, the OAP Deployment runs
+# `-Dmode=no-init`). This is the deployment that exercises the runtime-rule
+# schema-change path on a no-init node: applying a NEW MAL rule must drive the
+# backend DDL (create the BanyanDB measure) on the cluster main even though it is a
+# no-init OAP — the init Job never knew about a metric created at runtime, so the
+# main is the only node that can create it.
#
-# Coverage:
-# 1. Apply seed-rule on OAP-1 → ACTIVE
-# 2. Wait for OAP-2 to see the rule via /list (one tick = ~30 s default)
-# 3. STRUCTURAL update on OAP-1 → re-converge on OAP-2 (different content hash)
+# Coverage (drive on OAP-1, observe convergence on OAP-2 within a reconciler tick):
+# 1. Apply seed-rule on OAP-1 → ACTIVE (NEW: first-time measure creation on no-init)
+# 2. OAP-2 converges on the same (status, contentHash)
+# 3. STRUCTURAL update on OAP-1 → re-converge on OAP-2 (new metric, new measure)
# 4. Inactivate on OAP-1 → INACTIVE on OAP-2
# 5. Delete on OAP-1 → row gone on OAP-2
#
+# The pre-fix bug: on a no-init OAP the apply blocked forever in the storage
+# installer's init-node poll loop and never created the measure, so step 1 never
+# reached ACTIVE. Reaching ACTIVE here is the end-to-end regression assertion.
+#
# Failures route to stderr so the e2e harness's stdout capture stays clean.
set -euo pipefail
@@ -33,26 +42,66 @@ set -euo pipefail
log() { echo "[cluster-flow] $*" >&2; }
fail() { log "FAIL: $*"; exit 1; }
+NS="${SW_NAMESPACE:-skywalking}"
+# Pod-template labels set by the skywalking-helm OAP Deployment (release name = skywalking).
+OAP_SELECTOR="${OAP_SELECTOR:-app=skywalking,component=oap,release=skywalking}"
OAP1_PORT="${OAP1_PORT:-17128}"
OAP2_PORT="${OAP2_PORT:-17129}"
OAP1_BASE="http://127.0.0.1:${OAP1_PORT}"
OAP2_BASE="http://127.0.0.1:${OAP2_PORT}"
+# Admin REST port inside each OAP container (SW_ADMIN_SERVER=default).
+ADMIN_CONTAINER_PORT="${ADMIN_CONTAINER_PORT:-17128}"
+
SEED_DIR="${SEED_DIR:-$(pwd)/test/e2e-v2/cases/runtime-rule/mal-storage/seed-rules}"
SEED_NEW="${SEED_DIR}/seed-rule.yaml"
SEED_STRUCT="${SEED_DIR}/seed-rule-structural.yaml"
CATALOG="otel-rules"
NAME="cluster_rr"
-# Two ticks worth — default reconciler interval is 30 s; allow a generous 90 s for
-# convergence on a busy CI host.
-CONVERGE_TIMEOUT_S="${CONVERGE_TIMEOUT_S:-90}"
+# Generous on a kind host: two reconciler ticks (default 30 s) + BanyanDB schema
+# propagation + RPC jitter.
+CONVERGE_TIMEOUT_S="${CONVERGE_TIMEOUT_S:-120}"
[ -f "${SEED_NEW}" ] || fail "seed-rule.yaml missing at ${SEED_NEW}"
+[ -f "${SEED_STRUCT}" ] || fail "seed-rule-structural.yaml missing at ${SEED_STRUCT}"
+
+# --- Discover the two OAP pods and port-forward each node's admin REST -------------
+# The OAP Deployment runs >= 2 replicas behind one Service; the Service load-balances,
+# so addressing individual nodes (to assert cross-node convergence) needs per-pod
+# forwards rather than a single Service forward.
+log "waiting for >= 2 ready OAP pods in ns/${NS} (selector: ${OAP_SELECTOR})"
+deadline=$(( $(date +%s) + 300 ))
+PODS=()
+while true; do
+ # Only Ready pods — a no-init OAP keeps port 12800 closed (and stays NotReady)
+ # until the init Job has created the static schema. Read into an array without
+ # mapfile/readarray so the script runs under macOS bash 3.2 as well as CI bash 4+.
+ PODS=()
+ while IFS= read -r _pod; do
+ [ -n "${_pod}" ] && PODS+=("${_pod}")
+ done < <(kubectl -n "${NS}" get pods -l "${OAP_SELECTOR}" \
+ -o jsonpath='{range .items[*]}{range @.status.conditions[?(@.type=="Ready")]}{@.status}{end} {.metadata.name}{"\n"}{end}' \
+ 2>/dev/null | awk '$1=="True"{print $2}')
+ if [ "${#PODS[@]}" -ge 2 ]; then
+ break
+ fi
+ if [ "$(date +%s)" -ge "${deadline}" ]; then
+ kubectl -n "${NS}" get pods -l "${OAP_SELECTOR}" >&2 || true
+ fail "fewer than 2 ready OAP pods after 300s (got ${#PODS[@]})"
+ fi
+ sleep 5
+done
+POD1="${PODS[0]}"
+POD2="${PODS[1]}"
+log "OAP pods: OAP-1=${POD1} OAP-2=${POD2}"
-# All runtime-rule REST calls go through swctl's `admin` command tree instead of
-# raw curl. This flow drives two OAP nodes, so the admin host (`--admin-url`) is
-# passed per call as the first argument. `--display json` keeps the body shape
-# identical to the old curl output, so the jq assertions are unchanged.
+kubectl -n "${NS}" port-forward "pod/${POD1}" "${OAP1_PORT}:${ADMIN_CONTAINER_PORT}" >/dev/null 2>&1 &
+PF1=$!
+kubectl -n "${NS}" port-forward "pod/${POD2}" "${OAP2_PORT}:${ADMIN_CONTAINER_PORT}" >/dev/null 2>&1 &
+PF2=$!
+trap 'kill "${PF1}" "${PF2}" 2>/dev/null || true' EXIT
+
+# --- swctl admin helpers (per-node --admin-url) ------------------------------------
admin() { local base="$1"; shift; swctl --display json --admin-url="${base}" admin "$@"; }
list_row() {
@@ -64,26 +113,17 @@ list_row() {
| head -1
}
-list_status() {
- local base="$1"
- list_row "${base}" | jq -r '.status // empty'
-}
-
-list_hash() {
- local base="$1"
- list_row "${base}" | jq -r '.contentHash // empty'
-}
+list_status() { list_row "$1" | jq -r '.status // empty'; }
+list_hash() { list_row "$1" | jq -r '.contentHash // empty'; }
+list_apply_error() { list_row "$1" | jq -r '.lastApplyError // empty'; }
await_status() {
local base="$1" expected="$2" deadline=$(( $(date +%s) + CONVERGE_TIMEOUT_S ))
while true; do
- local got
- got="$(list_status "${base}")"
- if [ "${got}" = "${expected}" ]; then
- return 0
- fi
+ local got; got="$(list_status "${base}")"
+ [ "${got}" = "${expected}" ] && return 0
if [ "$(date +%s)" -ge "${deadline}" ]; then
- fail "${base} did not reach status='${expected}' within ${CONVERGE_TIMEOUT_S}s (last='${got}')"
+ fail "${base} did not reach status='${expected}' within ${CONVERGE_TIMEOUT_S}s (last='${got}', applyError='$(list_apply_error "${base}")')"
fi
sleep 2
done
@@ -92,11 +132,8 @@ await_status() {
await_hash() {
local base="$1" expected_hash="$2" deadline=$(( $(date +%s) + CONVERGE_TIMEOUT_S ))
while true; do
- local got
- got="$(list_hash "${base}")"
- if [ "${got}" = "${expected_hash}" ]; then
- return 0
- fi
+ local got; got="$(list_hash "${base}")"
+ [ "${got}" = "${expected_hash}" ] && return 0
if [ "$(date +%s)" -ge "${deadline}" ]; then
fail "${base} did not converge to contentHash='${expected_hash:0:8}…' within ${CONVERGE_TIMEOUT_S}s (last='${got:0:8}…')"
fi
@@ -107,9 +144,7 @@ await_hash() {
await_absent() {
local base="$1" deadline=$(( $(date +%s) + CONVERGE_TIMEOUT_S ))
while true; do
- if [ -z "$(list_row "${base}")" ]; then
- return 0
- fi
+ [ -z "$(list_row "${base}")" ] && return 0
if [ "$(date +%s)" -ge "${deadline}" ]; then
fail "${base} did not drop row within ${CONVERGE_TIMEOUT_S}s"
fi
@@ -117,43 +152,46 @@ await_absent() {
done
}
+assert_no_apply_error() {
+ local base="$1" err; err="$(list_apply_error "${base}")"
+ [ -z "${err}" ] || fail "${base} reports lastApplyError='${err}' (no-init schema change failed)"
+}
+
apply_on() {
local base="$1" body="$2" extra="${3:-}"
local -a flags=(--catalog "${CATALOG}" --name "${NAME}" -f "${body}")
[[ "${extra}" == *allowStorageChange=true* ]] && flags+=(--allow-storage-change)
- local resp; resp="$(admin "${base}" runtime-rule add "${flags[@]}")" \
- || fail "addOrUpdate against ${base} failed"
- echo "${resp}"
+ admin "${base}" runtime-rule add "${flags[@]}" || fail "addOrUpdate against ${base} failed"
}
-# --- Wait for both OAPs to come up -------------------------------------------------
-log "waiting for OAP-1 (${OAP1_BASE})"
-deadline=$(( $(date +%s) + 120 ))
-until admin "${OAP1_BASE}" runtime-rule list >/dev/null 2>&1; do
- if [ "$(date +%s)" -ge "${deadline}" ]; then fail "OAP-1 not ready after 120s"; fi
- sleep 2
-done
-log "waiting for OAP-2 (${OAP2_BASE})"
-deadline=$(( $(date +%s) + 120 ))
-until admin "${OAP2_BASE}" runtime-rule list >/dev/null 2>&1; do
- if [ "$(date +%s)" -ge "${deadline}" ]; then fail "OAP-2 not ready after 120s"; fi
- sleep 2
+# --- Wait for both OAPs' admin REST to answer through the forwards -----------------
+for pair in "OAP-1 ${OAP1_BASE}" "OAP-2 ${OAP2_BASE}"; do
+ set -- ${pair}; label="$1"; base="$2"
+ log "waiting for ${label} admin REST (${base})"
+ deadline=$(( $(date +%s) + 120 ))
+ until admin "${base}" runtime-rule list >/dev/null 2>&1; do
+ if [ "$(date +%s)" -ge "${deadline}" ]; then fail "${label} admin not ready after 120s"; fi
+ sleep 2
+ done
done
-log "both OAPs ready"
+log "both OAP admin endpoints ready"
-# --- Phase 1: apply on OAP-1, observe convergence on OAP-2 -------------------------
-log "=== Phase 1: apply (NEW) on OAP-1 ==="
+# --- Phase 1: apply NEW on OAP-1 — first-time measure creation on a no-init node ----
+log "=== Phase 1: apply (NEW) on OAP-1 — exercises no-init schema creation ==="
apply_on "${OAP1_BASE}" "${SEED_NEW}" >/dev/null
await_status "${OAP1_BASE}" "ACTIVE"
+assert_no_apply_error "${OAP1_BASE}"
hash_initial="$(list_hash "${OAP1_BASE}")"
-log "OAP-1 → ACTIVE @ ${hash_initial:0:8}…"
+log "OAP-1 → ACTIVE @ ${hash_initial:0:8}… (measure created on a no-init OAP)"
await_status "${OAP2_BASE}" "ACTIVE"
await_hash "${OAP2_BASE}" "${hash_initial}"
log "OAP-2 converged to ${hash_initial:0:8}…"
-# --- Phase 2: STRUCTURAL update on OAP-1, observe new hash on OAP-2 ----------------
+# --- Phase 2: STRUCTURAL update on OAP-1 — second measure created on no-init --------
log "=== Phase 2: STRUCTURAL on OAP-1 ==="
apply_on "${OAP1_BASE}" "${SEED_STRUCT}" "allowStorageChange=true" >/dev/null
+await_status "${OAP1_BASE}" "ACTIVE"
+assert_no_apply_error "${OAP1_BASE}"
hash_struct="$(list_hash "${OAP1_BASE}")"
[ "${hash_struct}" != "${hash_initial}" ] || fail "OAP-1 contentHash unchanged after STRUCTURAL apply"
log "OAP-1 → ACTIVE @ ${hash_struct:0:8}… (was ${hash_initial:0:8}…)"
@@ -178,4 +216,33 @@ log "OAP-1 → row gone"
await_absent "${OAP2_BASE}"
log "OAP-2 converged: row gone"
-log "=== ALL CLUSTER PHASES PASSED ==="
+# --- Phase 5: forward-path coverage — drive a write on OAP-2 -----------------------
+# Phases 1-4 drove OAP-1; whether that exercised the cross-node Forward depends on which
+# node the hash-router picked as main. Driving a write on OAP-2 as well guarantees the
+# Forward path is exercised regardless: whichever of OAP-1 / OAP-2 is NOT the main forwards
+# the write to the main. This is the path that regressed on Kubernetes — every replica
+# shared selfNodeId=0.0.0.0_11800 (the 0.0.0.0 gRPC bind host), so the main's self-loop
+# guard rejected a legitimate forward as HTTP 400 forward_self_loop. With a unique per-pod
+# id the forward completes; a failure here (esp. forward_self_loop) re-opens that bug.
+NAME_B="cluster_rr_fwd"
+log "=== Phase 5: apply on OAP-2 (guarantees cross-node Forward coverage) ==="
+admin "${OAP2_BASE}" runtime-rule add --catalog "${CATALOG}" --name "${NAME_B}" -f "${SEED_NEW}" >/dev/null \
+ || fail "addOrUpdate on OAP-2 failed — cross-node Forward broken (e.g. forward_self_loop)?"
+b_deadline=$(( $(date +%s) + CONVERGE_TIMEOUT_S ))
+while true; do
+ b_status="$(admin "${OAP2_BASE}" runtime-rule list 2>/dev/null \
+ | jq -r '.rules[] | select(.catalog=="'"${CATALOG}"'" and .name=="'"${NAME_B}"'") | .status' | head -1)"
+ [ "${b_status}" = "ACTIVE" ] && break
+ [ "$(date +%s)" -ge "${b_deadline}" ] && fail "OAP-2 write did not reach ACTIVE within ${CONVERGE_TIMEOUT_S}s (last='${b_status}')"
+ sleep 2
+done
+log "OAP-2 write → ACTIVE (cross-node Forward path OK)"
+# Cleanup also forwards from OAP-2: inactivate (soft-pause) is required before delete,
+# so this exercises the Forward path for the inactivate + delete operations too.
+admin "${OAP2_BASE}" runtime-rule inactivate --catalog "${CATALOG}" --name "${NAME_B}" >/dev/null \
+ || fail "inactivate of ${NAME_B} on OAP-2 failed"
+admin "${OAP2_BASE}" runtime-rule delete --catalog "${CATALOG}" --name "${NAME_B}" >/dev/null \
+ || fail "cleanup delete of ${NAME_B} on OAP-2 failed"
+log "Phase 5 cleanup done (inactivate + delete forwarded OK)"
+
+log "=== ALL CLUSTER (kind) PHASES PASSED ==="
diff --git a/test/e2e-v2/cases/runtime-rule/cluster/docker-compose.yml b/test/e2e-v2/cases/runtime-rule/cluster/docker-compose.yml
deleted file mode 100644
index c82047c54195..000000000000
--- a/test/e2e-v2/cases/runtime-rule/cluster/docker-compose.yml
+++ /dev/null
@@ -1,93 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one or more
-# contributor license agreements. See the NOTICE file distributed with
-# this work for additional information regarding copyright ownership.
-# The ASF licenses this file to You under the Apache License, Version 2.0
-# (the "License"); you may not use this file except in compliance with
-# the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# Cluster convergence — 2 OAPs behind a ZooKeeper coordinator + BanyanDB.
-# Verifies that a runtime-rule apply on one node propagates to the other within a
-# reconciler tick (default 30 s) and that the Suspend / Resume RPC bracket dispatch
-# correctly across the cluster.
-services:
- zookeeper:
- image: zookeeper:3.8
- networks:
- - e2e
- environment:
- ZOO_4LW_COMMANDS_WHITELIST: "ruok,stat,srvr"
- healthcheck:
- # Use the zookeeper-shell.sh ls wrapper (image's own /bin) — the official
- # zookeeper:3.8 image does not ship `nc`, so the more obvious `echo ruok | nc ...`
- # idiom fails. zkServer.sh status returns 0 once the server is in standalone /
- # leader mode.
- test: ["CMD-SHELL", "zkServer.sh status 2>/dev/null | grep -E 'Mode: (standalone|leader|follower)'"]
- interval: 5s
- timeout: 10s
- retries: 30
-
- banyandb:
- extends:
- file: ../../../script/docker-compose/base-compose.yml
- service: banyandb
-
- oap1:
- extends:
- file: ../../../script/docker-compose/base-compose.yml
- service: oap
- hostname: oap1
- environment:
- SW_ADMIN_SERVER: default
- SW_RECEIVER_RUNTIME_RULE: default
- SW_STORAGE: banyandb
- SW_CLUSTER: zookeeper
- SW_CLUSTER_ZK_HOST_PORT: zookeeper:2181
- # First-up node also doubles as the static-rule installer; nothing to coordinate
- # with peers on storage init.
- ports:
- - "11800:11800"
- - "12800:12800"
- - "17128:17128"
- depends_on:
- zookeeper:
- condition: service_healthy
- banyandb:
- condition: service_healthy
- networks:
- - e2e
-
- oap2:
- extends:
- file: ../../../script/docker-compose/base-compose.yml
- service: oap
- hostname: oap2
- environment:
- SW_ADMIN_SERVER: default
- SW_RECEIVER_RUNTIME_RULE: default
- SW_STORAGE: banyandb
- SW_CLUSTER: zookeeper
- SW_CLUSTER_ZK_HOST_PORT: zookeeper:2181
- ports:
- - "11801:11800"
- - "12801:12800"
- - "17129:17128"
- depends_on:
- zookeeper:
- condition: service_healthy
- banyandb:
- condition: service_healthy
- oap1:
- condition: service_healthy
- networks:
- - e2e
-
-networks:
- e2e:
diff --git a/test/e2e-v2/cases/runtime-rule/cluster/e2e.yaml b/test/e2e-v2/cases/runtime-rule/cluster/e2e.yaml
index d6b82f0c8de1..d0dbc3bee973 100644
--- a/test/e2e-v2/cases/runtime-rule/cluster/e2e.yaml
+++ b/test/e2e-v2/cases/runtime-rule/cluster/e2e.yaml
@@ -13,17 +13,32 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-# 2-OAP cluster + ZK + BanyanDB. Drives apply / inactivate / delete on OAP-1 and
-# verifies OAP-2 converges within a reconciler tick (default 30 s).
+# Runtime-rule lifecycle + cross-node convergence on a Kubernetes (kind) cluster
+# deployed via the skywalking-helm chart — a 2-replica OAP Deployment running in
+# NO-INIT mode (`-Dmode=no-init`) behind a one-shot `-Dmode=init` schema-init Job,
+# the exact topology every production SkyWalking cluster uses. This is the case that
+# exercises the runtime-rule schema-change path on a no-init OAP: an operator apply
+# must drive backend DDL (create the BanyanDB measure) on the cluster main even
+# though it is a no-init node. BanyanDB native cluster coordination (SW_CLUSTER=
+# kubernetes) is wired by the chart; ZooKeeper is not needed.
setup:
- env: compose
- file: docker-compose.yml
- timeout: 25m
+ env: kind
+ file: kind.yaml
+ timeout: 30m
init-system-environment: ../../../script/env
+ kind:
+ import-images:
+ - skywalking/oap:latest
steps:
- name: set PATH
command: export PATH=/tmp/skywalking-infra-e2e/bin:$PATH
+ - name: install yq
+ command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh yq
+ - name: install swctl
+ command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh swctl
+ - name: install kubectl
+ command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh kubectl
- name: install jq
command: |
if ! command -v jq >/dev/null 2>&1; then
@@ -31,11 +46,42 @@ setup:
https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64
chmod +x /tmp/skywalking-infra-e2e/bin/jq
fi
- - name: install swctl
- command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh swctl
- - name: drive cluster convergence flow
+ - name: install helm
+ command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh helm
+ # 2-replica OAP Deployment (no-init) + init Job + admin server + runtime-rule
+ # receiver + BanyanDB. fullnameOverride=skywalking makes the OAP Service / pods
+ # discoverable as skywalking-oap with labels app=skywalking,component=oap.
+ - name: install SkyWalking (no-init cluster + BanyanDB) via helm
+ command: |
+ export PATH=/tmp/skywalking-infra-e2e/bin:$PATH
+ helm -n skywalking install skywalking \
+ oci://ghcr.io/apache/skywalking-helm/skywalking-helm \
+ --version "0.0.0-${SW_KUBERNETES_COMMIT_SHA}" \
+ --create-namespace \
+ --set fullnameOverride=skywalking \
+ --set elasticsearch.enabled=false \
+ --set oap.replicas=2 \
+ --set oap.image.repository=skywalking/oap \
+ --set oap.image.tag=latest \
+ --set oap.imagePullPolicy=IfNotPresent \
+ --set oap.storageType=banyandb \
+ --set oap.env.SW_ADMIN_SERVER=default \
+ --set oap.env.SW_RECEIVER_RUNTIME_RULE=default \
+ --set ui.enabled=false \
+ --set banyandb.enabled=true \
+ --set banyandb.standalone.enabled=true \
+ --set banyandb.cluster.enabled=false \
+ --set banyandb.image.repository=ghcr.io/apache/skywalking-banyandb \
+ --set banyandb.image.tag=${SW_BANYANDB_COMMIT}
+ wait:
+ # The init Job must complete (creates the static schema) before the no-init
+ # OAP Deployment can become Available.
+ - namespace: skywalking
+ resource: deployment/skywalking-oap
+ for: condition=available
+ timeout: 20m
+ - name: drive runtime-rule lifecycle + cross-node convergence (no-init)
command: |
- set -euo pipefail
export PATH=/tmp/skywalking-infra-e2e/bin:$PATH
bash test/e2e-v2/cases/runtime-rule/cluster/cluster-flow.sh
@@ -44,18 +90,8 @@ verify:
count: 1
interval: 1s
cases:
- - query: swctl --display json --admin-url=http://127.0.0.1:17128 admin runtime-rule list >/dev/null && echo ok
- expected: expected/ok.txt
-
-cleanup:
- on: always
- collect:
- on: failure
- output-dir: $SW_INFRA_E2E_LOG_DIR/runtime-rule/cluster
- items:
- - service: oap1
- paths:
- - /skywalking/logs/
- - service: oap2
- paths:
- - /skywalking/logs/
+ # The lifecycle assertions live in cluster-flow.sh (it exits non-zero on any
+ # failure, failing setup). This is a thin liveness check that the no-init cluster
+ # came up with both OAP replicas Ready.
+ - query: kubectl -n skywalking get deployment skywalking-oap -o jsonpath='{.status.readyReplicas}'
+ expected: expected/ready-replicas.txt
diff --git a/test/e2e-v2/cases/runtime-rule/cluster/expected/ok.txt b/test/e2e-v2/cases/runtime-rule/cluster/expected/ok.txt
deleted file mode 100644
index 9766475a4185..000000000000
--- a/test/e2e-v2/cases/runtime-rule/cluster/expected/ok.txt
+++ /dev/null
@@ -1 +0,0 @@
-ok
diff --git a/test/e2e-v2/cases/runtime-rule/cluster/expected/ready-replicas.txt b/test/e2e-v2/cases/runtime-rule/cluster/expected/ready-replicas.txt
new file mode 100644
index 000000000000..d8263ee98605
--- /dev/null
+++ b/test/e2e-v2/cases/runtime-rule/cluster/expected/ready-replicas.txt
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git a/test/e2e-v2/cases/runtime-rule/cluster/kind.yaml b/test/e2e-v2/cases/runtime-rule/cluster/kind.yaml
new file mode 100644
index 000000000000..a57ada71203d
--- /dev/null
+++ b/test/e2e-v2/cases/runtime-rule/cluster/kind.yaml
@@ -0,0 +1,23 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Single-node kind cluster for the runtime-rule no-init cluster e2e. One node is
+# enough — the OAP Deployment's replicas (the no-init cluster) and the BanyanDB pod
+# all schedule here. Node image pinned to the same k8s 1.28 build the istio e2e uses.
+kind: Cluster
+apiVersion: kind.x-k8s.io/v1alpha4
+nodes:
+ - role: control-plane
+ image: kindest/node:v1.28.15@sha256:a7c05c7ae043a0b8c818f5a06188bc2c4098f6cb59ca7d1856df00375d839251
- *
- *
- *
*
- *
- *
*
- * {@link #verifySchemaOnly()} — {@link Mode#VERIFY_SCHEMA_ONLY} (predicate: {@link #isVerifySchemaOnly()})
*
- *
*