diff --git a/docs/reference/UDS Core/prerequisites.md b/docs/reference/UDS Core/prerequisites.md
index 84de76ef8e..9faf310e56 100644
--- a/docs/reference/UDS Core/prerequisites.md
+++ b/docs/reference/UDS Core/prerequisites.md
@@ -68,12 +68,21 @@ In addition, to run Istio ingress gateways (part of Core) you will need to ensur
##### Ambient Mode
-Istio can be deployed in [Ambient Mode](https://istio.io/latest/docs/ambient/overview/) by deploying the optional `istio-ambient` component. This mode is still in alpha release and is not recommended for production use or for clusters requiring `FIPS` compliance. The `istio-ambient` component installs the Istio CNI plugin which requires specifying the `CNI_CONF_DIR` and `CNI_BIN_DIR` variables. These values can change based on the environment Istio is being deployed into. By default the package will attempt to auto-detect these values and will use the following values if not specified:
+Istio can be deployed in [Ambient Mode](https://istio.io/latest/docs/ambient/overview/) by deploying the optional `istio-ambient` component. This mode is still in alpha release and is not recommended for production use. Also note that only the `unicorn` and `registry1` flavors of core contain `FIPS` compliant images. The `istio-ambient` component is **required** if you want to use UDS Packages with `spec.network.serviceMesh.mode: ambient`. If Ambient mode is not deployed in the cluster, packages configured for ambient mode will automatically fall back to sidecar mode.
+
+When using ambient mode with UDS Packages, you can benefit from:
+- Reduced resource overhead compared to sidecar mode, as workloads don't require an injected sidecar container
+- Simplified deployment and operations for service mesh capabilities
+- Faster pod startup times since there's no need to wait for sidecar initialization
+
+Note that Packages with Authservice clients are not currently supported in ambient mode and will be rejected by the UDS Operator.
+
+The `istio-ambient` component installs the Istio CNI plugin which requires specifying the `CNI_CONF_DIR` and `CNI_BIN_DIR` variables. These values can change based on the environment Istio is being deployed into. By default the package will attempt to auto-detect these values and will use the following values if not specified:
```yaml
# K3d cluster
cniConfDir: /var/lib/rancher/k3s/agent/etc/cni/net.d
-cniBinDir: /bin/
+cniBinDir: /opt/cni/bin/ # Historically this was `/bin/`
# K3s cluster
cniConfDir: /var/lib/rancher/k3s/agent/etc/cni/net.d
@@ -160,7 +169,6 @@ sysctl -p
Metrics server is provided as an optional component in UDS Core and can be enabled if needed. For distros where metrics-server is already provided, ensure that you do NOT enable metrics-server. See the below as an example for enabling metrics-server if your cluster does not include it.
```yaml
----
- name: uds-core
repository: ghcr.io/defenseunicorns/packages/private/uds/core
ref: 0.25.2-unicorn
diff --git a/docs/reference/configuration/Single Sign-On/auth-service.md b/docs/reference/configuration/Single Sign-On/auth-service.md
index b0f2ebc688..62a2c23716 100644
--- a/docs/reference/configuration/Single Sign-On/auth-service.md
+++ b/docs/reference/configuration/Single Sign-On/auth-service.md
@@ -33,3 +33,5 @@ For a complete example, see [app-authservice-tenant.yaml](https://github.com/def
## Limitations:
Authservice is intended for simple, basic protection scenarios where an absolute level of protection is acceptable. For more advanced authentication requirements, you should implement authentication directly in your application or via a more comprehensive solution.
+
+Authservice is currently not supported for ambient workloads (Package CR `network.serviceMesh.mode` of `ambient`). Package CRs with ambient configuration will be denied when applying if an Authservice SSO client is present. This restriction will be removed in the future once supported is added for this in the UDS Operator.
diff --git a/docs/reference/configuration/UDS operator/package.md b/docs/reference/configuration/UDS operator/package.md
index 4df924cf3f..a9a85e38ff 100644
--- a/docs/reference/configuration/UDS operator/package.md
+++ b/docs/reference/configuration/UDS operator/package.md
@@ -8,6 +8,8 @@ title: UDS Package
- **Enabling Istio Sidecar Injection:**
- The operator facilitates the activation of Istio sidecar injection within namespaces where the CR is deployed.
+- **Support for Istio Ambient Mode:**
+ - Packages can now opt into Istio's Ambient mode by setting `spec.network.serviceMesh.mode: ambient`. This provides service mesh capabilities and security without sidecars, reducing resource overhead.
- **Establishing Default-Deny Ingress/Egress Network Policies:**
- It sets up default-deny network policies for both ingress and egress, creating a foundational security posture.
- **Implementing Layered Allow-List Approach:**
@@ -24,7 +26,9 @@ title: UDS Package
- Authservice authentication provides application agnostic SSO for applications that opt-in.
:::caution
-Warning: **Authservice Protection** and **SSO Group Authentication** are in Alpha and may not be stable. Avoid using in production. Feedback is appreciated to improve reliability.
+Warning: **Istio Ambient Mode** Package support is in Alpha and may not be stable. Different workloads may experience issues when migrating away from sidecars so testing in a development/staging environment is encouraged. In addition there are some known limitations with ambient support at this time:
+- `Package` CRs with AuthService SSO clients (`enableAuthserviceSelector`) are not supported in ambient mode. This is a limitation we plan to remove with operator support/configuration of [waypoint proxies](https://istio.io/latest/docs/ambient/usage/waypoint/), track progress on [this issue in GitHub](https://github.com/defenseunicorns/uds-core/issues/1200).
+- Metrics of applications in ambient mode will _NOT_ be scraped successfully by Prometheus when STRICT mTLS is being enforced (there may be workarounds to scrape in PERMISSIVE mode but this is not advised). This will be resolved once Prometheus is migrated to ambient mode.
:::
### Example UDS Package CR
@@ -92,4 +96,4 @@ This example may not contain all fields, the full specification for the Package
:::note
More SSO Package examples might be found [here](/reference/configuration/single-sign-on/overview/).
-:::
\ No newline at end of file
+:::
diff --git a/docs/reference/configuration/custom resources/packages-v1alpha1-cr.md b/docs/reference/configuration/custom resources/packages-v1alpha1-cr.md
index 985a688e2b..9911ab693e 100644
--- a/docs/reference/configuration/custom resources/packages-v1alpha1-cr.md
+++ b/docs/reference/configuration/custom resources/packages-v1alpha1-cr.md
@@ -108,7 +108,7 @@ sidebar:
+
+
+#### ServiceMesh
+
+
+
+ | Field |
+ Type |
+ Description |
+
+
+
+ | mode | string (enum): | Set the service mesh mode for this package (namespace), defaults to sidecar |
+
+
+
+
diff --git a/schemas/package-v1alpha1.schema.json b/schemas/package-v1alpha1.schema.json
index bc79626ccc..fa889cffb3 100644
--- a/schemas/package-v1alpha1.schema.json
+++ b/schemas/package-v1alpha1.schema.json
@@ -155,6 +155,10 @@
"$ref": "#/definitions/Expose"
},
"description": "Expose a service on an Istio Gateway"
+ },
+ "serviceMesh": {
+ "$ref": "#/definitions/ServiceMesh",
+ "description": "Service Mesh configuration for the package"
}
},
"required": [],
@@ -787,6 +791,19 @@
"required": [],
"title": "FluffyURI"
},
+ "ServiceMesh": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "mode": {
+ "$ref": "#/definitions/Mode",
+ "description": "Set the service mesh mode for this package (namespace), defaults to sidecar"
+ }
+ },
+ "required": [],
+ "title": "ServiceMesh",
+ "description": "Service Mesh configuration for the package"
+ },
"Sso": {
"type": "object",
"additionalProperties": false,
@@ -1112,6 +1129,15 @@
"title": "Gateway",
"description": "The name of the gateway to expose the service on (default: tenant)"
},
+ "Mode": {
+ "type": "string",
+ "enum": [
+ "sidecar",
+ "ambient"
+ ],
+ "title": "Mode",
+ "description": "Set the service mesh mode for this package (namespace), defaults to sidecar"
+ },
"ClientAuthenticatorType": {
"type": "string",
"enum": [
diff --git a/src/pepr/config.ts b/src/pepr/config.ts
index 9ae7d4bbed..f1a9abf8a3 100644
--- a/src/pepr/config.ts
+++ b/src/pepr/config.ts
@@ -44,6 +44,9 @@ export const UDSConfig = {
// Track if UDS Core identity-authorization layer is deployed
isIdentityDeployed: false,
+
+ // Track if Istio Ambient optional components are deployed
+ isAmbientDeployed: false,
};
// configure subproject logger
diff --git a/src/pepr/operator/controllers/istio/injection.ts b/src/pepr/operator/controllers/istio/injection.ts
deleted file mode 100644
index 14cefcbdce..0000000000
--- a/src/pepr/operator/controllers/istio/injection.ts
+++ /dev/null
@@ -1,187 +0,0 @@
-/**
- * Copyright 2024 Defense Unicorns
- * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial
- */
-
-import { K8s, kind } from "pepr";
-
-import { Component, setupLogger } from "../../../logger";
-import { UDSPackage } from "../../crd";
-
-// configure subproject logger
-const log = setupLogger(Component.OPERATOR_ISTIO);
-
-const injectionLabel = "istio-injection";
-const injectionAnnotation = "uds.dev/original-istio-injection";
-
-/**
- * Syncs the package namespace istio-injection label and adds a label for the package name
- *
- * @param pkg
- */
-export async function enableInjection(pkg: UDSPackage) {
- if (!pkg.metadata?.namespace || !pkg.metadata.name) {
- throw new Error(`Invalid Package definition, missing namespace or name`);
- }
-
- const sourceNS = await K8s(kind.Namespace).Get(pkg.metadata.namespace);
- const labels = { ...(sourceNS.metadata?.labels || {}) };
- const originalInjectionLabel = labels[injectionLabel];
- const annotations = { ...(sourceNS.metadata?.annotations || {}) };
- const pkgKey = `uds.dev/pkg-${pkg.metadata.name}`;
-
- // Mark the original namespace injection setting for if all packages are removed
- if (!annotations[injectionAnnotation]) {
- annotations[injectionAnnotation] = originalInjectionLabel || "non-existent";
- }
-
- let shouldRestartPods = false;
-
- // Ensure Istio injection is enabled
- if (originalInjectionLabel !== "enabled") {
- labels[injectionLabel] = "enabled";
- log.info(`Enabling Istio injection for namespace ${pkg.metadata.namespace}.`);
- shouldRestartPods = true; // Pods need restarting due to label change
- }
-
- // Ensure package-specific annotation is updated
- if (annotations[pkgKey] !== "true") {
- annotations[pkgKey] = "true";
- log.info(
- `Updating package-specific annotation for ${pkg.metadata.name} in namespace ${pkg.metadata.namespace}.`,
- );
- }
-
- // Apply namespace updates if there are changes
- const updatedNamespace = {
- metadata: {
- name: pkg.metadata.namespace,
- labels,
- annotations,
- },
- };
-
- if (
- JSON.stringify(sourceNS.metadata?.labels) !== JSON.stringify(labels) ||
- JSON.stringify(sourceNS.metadata?.annotations) !== JSON.stringify(annotations)
- ) {
- log.debug(`Applying updates to namespace ${pkg.metadata.namespace}.`);
- await K8s(kind.Namespace).Apply(updatedNamespace, { force: true });
- } else {
- log.debug(`No namespace updates needed for ${pkg.metadata.namespace}.`);
- }
-
- // Restart pods if required
- if (shouldRestartPods) {
- log.debug(`Restarting pods in ${pkg.metadata.namespace} due to configuration changes.`);
- await killPods(pkg.metadata.namespace, true);
- }
-}
-
-/**
- * Restores the namespace
- *
- * @param pkg the package to cleanup
- */
-export async function cleanupNamespace(pkg: UDSPackage) {
- if (!pkg.metadata?.namespace || !pkg.metadata.name) {
- throw new Error(`Invalid Package definition, missing namespace or name`);
- }
-
- const sourceNS = await K8s(kind.Namespace).Get(pkg.metadata.namespace);
- const labels = sourceNS.metadata?.labels || {};
- const originalInjectionLabel = labels[injectionLabel];
- const annotations = sourceNS.metadata?.annotations || {};
-
- // Remove the package annotation
- delete annotations[`uds.dev/pkg-${pkg.metadata.name}`];
-
- // If there are no more UDS Package annotations, restore the original value of the istio-injection label
- if (!Object.keys(annotations).find(key => key.startsWith("uds.dev/pkg-"))) {
- labels[injectionLabel] = annotations[injectionAnnotation];
- // If the original value was non-existent, remove the label
- if (labels[injectionLabel] === "non-existent") {
- delete labels[injectionLabel];
- }
- delete annotations[injectionAnnotation];
- }
-
- // Apply the updated Namespace
- log.debug(`Updating namespace ${pkg.metadata.namespace}, removing istio injection labels.`);
- await K8s(kind.Namespace).Apply(
- {
- metadata: {
- name: pkg.metadata.namespace,
- labels,
- annotations,
- },
- },
- { force: true },
- );
-
- // Kill the pods if we changed the value of the istio-injection label
- if (originalInjectionLabel !== labels[injectionLabel]) {
- log.debug(
- `Attempting pod restart in ${pkg.metadata.namespace} based on istio injection label change`,
- );
- await killPods(pkg.metadata.namespace, false);
- }
-}
-
-/**
- * Forces deletion of pods with the incorrect istio sidecar state
- *
- * @param ns The namespace to target
- * @param enableInjection Whether injection is being enabled
- */
-async function killPods(ns: string, enableInjection: boolean) {
- // Get all pods in the namespace
- const pods = await K8s(kind.Pod).InNamespace(ns).Get();
- const groups: Record = {};
-
- // Group the pods by owner UID
- for (const pod of pods.items) {
- // Ignore pods that already have a deletion timestamp
- if (pod.metadata?.deletionTimestamp) {
- log.debug(`Ignoring Pod ${ns}/${pod.metadata?.name}, already being deleted`);
- continue;
- }
-
- // Checks both container (haven't switched to native sidecars yet) and initContainers for native sidecars
- const foundSidecar =
- pod.spec?.containers?.some(c => c.name === "istio-proxy") ||
- pod.spec?.initContainers?.some(c => c.name === "istio-proxy");
-
- // If enabling injection, ignore pods that already have the istio sidecar
- if (enableInjection && foundSidecar) {
- log.debug(`Ignoring Pod ${ns}/${pod.metadata?.name}, already has sidecar`);
- continue;
- }
-
- // If disabling injection, ignore pods that don't have the istio sidecar
- if (!enableInjection && !foundSidecar) {
- log.debug(`Ignoring Pod ${ns}/${pod.metadata?.name}, injection disabled`);
- continue;
- }
-
- // Get the UID of the owner of the pod or default to "other" (shouldn't happen)
- const controlledBy = pod.metadata?.ownerReferences?.find(ref => ref.controller)?.uid || "other";
- groups[controlledBy] = groups[controlledBy] || [];
- log.debug(`Adding Pod ${ns}/${pod.metadata?.name} to ${controlledBy} deletion list.`);
- groups[controlledBy].push(pod);
- }
-
- // Delete each group of pods
- for (const group of Object.values(groups)) {
- // If this is a statefulset, delete the pods in reverse name order
- if (group[0].metadata?.ownerReferences?.find(ref => ref.kind === "StatefulSet")) {
- group.sort((a, b) => (b.metadata?.name || "").localeCompare(a.metadata?.name || ""));
- }
-
- for (const pod of group) {
- const action = enableInjection ? "enable" : "remove";
- log.info(`Deleting pod ${ns}/${pod.metadata?.name} to ${action} the istio sidecar`);
- await K8s(kind.Pod).Delete(pod);
- }
- }
-}
diff --git a/src/pepr/operator/controllers/istio/namespace.spec.ts b/src/pepr/operator/controllers/istio/namespace.spec.ts
new file mode 100644
index 0000000000..8f87e172f9
--- /dev/null
+++ b/src/pepr/operator/controllers/istio/namespace.spec.ts
@@ -0,0 +1,1029 @@
+/**
+ * Copyright 2025 Defense Unicorns
+ * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial
+ */
+
+import { K8s, kind } from "pepr";
+import { UDSConfig } from "../../../config";
+import { UDSPackage } from "../../crd";
+import { Mode } from "../../crd/generated/package-v1alpha1";
+import { cleanupNamespace, enableIstio, IstioState, killPods } from "./namespace";
+
+// Import the utility functions for direct testing
+// Note: These need to be exported in namespace.ts for testing
+import { applyNamespaceUpdates, getCurrentIstioState, getIstioLabels } from "./namespace";
+
+jest.mock("pepr", () => {
+ const originalModule = jest.requireActual("pepr") as object;
+ return {
+ ...originalModule,
+ K8s: jest.fn(),
+ };
+});
+
+jest.mock("../../reconcilers", () => ({
+ writeEvent: jest.fn(),
+}));
+
+const mockApply = jest.fn();
+const mockGet = jest.fn();
+const mockPodGet = jest.fn().mockResolvedValue({ items: [] });
+const mockPodDelete = jest.fn().mockResolvedValue({});
+const mockPackageGet = jest
+ .fn()
+ .mockResolvedValue({ metadata: { namespace: "test-ns", name: "pkg-existing" } });
+
+describe("enableIstio", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (K8s as jest.Mock).mockImplementation(resourceKind => {
+ if (resourceKind === kind.Pod) {
+ return { InNamespace: jest.fn().mockReturnValue({ Get: mockPodGet }) };
+ }
+ if (resourceKind === kind.Namespace) {
+ return { Get: mockGet, Apply: mockApply };
+ }
+ if (resourceKind === UDSPackage) {
+ return { InNamespace: jest.fn().mockReturnValue({ Get: mockPackageGet }) };
+ }
+ return { Get: jest.fn() };
+ });
+ UDSConfig.isAmbientDeployed = true;
+ });
+
+ test("should not update when labels have same content but different order", async () => {
+ // Mock existing namespace with labels in one specific order
+ mockGet.mockResolvedValue({
+ metadata: {
+ labels: {
+ "b-label": "value-b",
+ "a-label": "value-a",
+ "istio-injection": "enabled",
+ },
+ annotations: {
+ "uds.dev/pkg-test-pkg": "true",
+ "uds.dev/original-istio-state": IstioState.None,
+ },
+ },
+ });
+
+ // Create a package that would normally generate the same labels (but potentially in different order)
+ const pkg: UDSPackage = {
+ metadata: { namespace: "test-ns", name: "test-pkg" },
+ spec: {},
+ };
+
+ // Run enableIstio
+ await enableIstio(pkg);
+
+ // No updates should be applied since content is the same, regardless of key order
+ expect(mockApply).not.toHaveBeenCalled();
+ });
+
+ test("should update when labels have different content", async () => {
+ // Mock existing namespace with labels that don't include istio-injection
+ mockGet.mockResolvedValue({
+ metadata: {
+ labels: {
+ "a-label": "value-a",
+ "b-label": "value-b",
+ },
+ annotations: {
+ "uds.dev/pkg-test-pkg": "true",
+ },
+ },
+ });
+
+ // Create a package that will cause enableIstio to generate different labels
+ const pkg: UDSPackage = {
+ metadata: { namespace: "test-ns", name: "test-pkg" },
+ spec: {},
+ };
+
+ // Run enableIstio (which should add istio-injection: enabled)
+ await enableIstio(pkg);
+
+ // Updates should be applied since content is different
+ expect(mockApply).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ labels: expect.objectContaining({ "istio-injection": "enabled" }),
+ }),
+ }),
+ { force: true },
+ );
+ });
+
+ test("should not update when annotations have same content but different order", async () => {
+ // Mock existing namespace with annotations in one specific order
+ mockGet.mockResolvedValue({
+ metadata: {
+ labels: { "istio-injection": "enabled" },
+ annotations: {
+ "z-annotation": "value-z",
+ "a-annotation": "value-a",
+ "uds.dev/pkg-test-pkg": "true",
+ "uds.dev/original-istio-state": IstioState.None,
+ },
+ },
+ });
+
+ // Create a package that would normally generate the same annotations (but potentially in different order)
+ const pkg: UDSPackage = {
+ metadata: { namespace: "test-ns", name: "test-pkg" },
+ spec: {},
+ };
+
+ // Run enableIstio
+ await enableIstio(pkg);
+
+ // No updates should be applied since content is the same, regardless of key order
+ expect(mockApply).not.toHaveBeenCalled();
+ });
+
+ test("package missing metadata", async () => {
+ const pkg: UDSPackage = { metadata: { name: "test-pkg" }, spec: {} };
+
+ try {
+ await enableIstio(pkg);
+ // Fail test if above expression doesn't throw anything.
+ expect(true).toBe(false);
+ } catch (e) {
+ expect(e).toBeInstanceOf(Error);
+ }
+ });
+
+ test("package already exists, second reconciliation", async () => {
+ mockGet.mockResolvedValue({
+ metadata: {
+ labels: { "istio-injection": "enabled" },
+ annotations: {
+ "uds.dev/pkg-test-pkg": "true",
+ "uds.dev/original-istio-state": IstioState.None,
+ },
+ },
+ });
+ const pkg: UDSPackage = { metadata: { namespace: "test-ns", name: "test-pkg" }, spec: {} };
+
+ await enableIstio(pkg);
+
+ expect(mockApply).not.toHaveBeenCalled();
+
+ // This is a cheap way to check if killPods was called
+ expect(mockPodGet).not.toHaveBeenCalled();
+ });
+
+ // Test that istio injection is applied for new packages without ambient mode
+ test("sidecar package in plain namespace", async () => {
+ mockGet.mockResolvedValue({ metadata: { labels: {}, annotations: {} } });
+ const pkg: UDSPackage = { metadata: { namespace: "test-ns", name: "test-pkg" }, spec: {} };
+
+ await enableIstio(pkg);
+
+ expect(mockApply).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ labels: { "istio-injection": "enabled" },
+ annotations: {
+ "uds.dev/pkg-test-pkg": "true",
+ "uds.dev/original-istio-state": IstioState.None,
+ },
+ }),
+ }),
+ { force: true },
+ );
+
+ // This is a cheap way to check if killPods was called
+ expect(mockPodGet).toHaveBeenCalled();
+ });
+
+ // Test that ambient mode is applied for new packages with ambient mode
+ test("ambient package in plain namespace", async () => {
+ mockGet.mockResolvedValue({ metadata: { labels: {}, annotations: {} } });
+ const pkg: UDSPackage = {
+ metadata: { namespace: "test-ns", name: "test-pkg" },
+ spec: { network: { serviceMesh: { mode: Mode.Ambient } } },
+ };
+
+ await enableIstio(pkg);
+
+ expect(mockApply).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ labels: { "istio.io/dataplane-mode": "ambient" },
+ annotations: expect.objectContaining({ "uds.dev/pkg-test-pkg": "true" }),
+ }),
+ }),
+ { force: true },
+ );
+
+ // This is a cheap way to check if killPods was called
+ expect(mockPodGet).not.toHaveBeenCalled();
+ });
+
+ // Test that ambient mode is applied and pods are cycled if original mode was injected
+ test("ambient package in injected namespace", async () => {
+ mockGet.mockResolvedValue({
+ metadata: { labels: { "istio-injection": "enabled" }, annotations: {} },
+ });
+ const pkg: UDSPackage = {
+ metadata: { namespace: "test-ns", name: "test-pkg" },
+ spec: { network: { serviceMesh: { mode: Mode.Ambient } } },
+ };
+
+ await enableIstio(pkg);
+
+ expect(mockApply).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ labels: { "istio.io/dataplane-mode": "ambient" },
+ annotations: expect.objectContaining({ "uds.dev/pkg-test-pkg": "true" }),
+ }),
+ }),
+ { force: true },
+ );
+
+ // This is a cheap way to check if killPods was called
+ expect(mockPodGet).toHaveBeenCalled();
+ });
+
+ // Test that ambient mode falls back to sidecar when ambient is not deployed
+ test("ambient package falls back to sidecar when ambient is not available", async () => {
+ // Temporarily set ambient mode to unavailable
+ UDSConfig.isAmbientDeployed = false;
+
+ mockGet.mockResolvedValue({ metadata: { labels: {}, annotations: {}, name: "test-ns" } });
+ const pkg: UDSPackage = {
+ metadata: { namespace: "test-ns", name: "test-pkg" },
+ spec: { network: { serviceMesh: { mode: Mode.Ambient } } },
+ };
+
+ await enableIstio(pkg);
+
+ // Should apply sidecar mode instead of ambient
+ expect(mockApply).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ labels: { "istio-injection": "enabled" },
+ annotations: expect.objectContaining({
+ "uds.dev/pkg-test-pkg": "true",
+ "uds.dev/original-istio-state": IstioState.None,
+ }),
+ }),
+ }),
+ { force: true },
+ );
+
+ // Verify warning event was written
+ expect(jest.requireMock("../../reconcilers").writeEvent).toHaveBeenCalledWith(
+ pkg,
+ expect.objectContaining({
+ reason: "AmbientUnavailable",
+ type: "Warning",
+ }),
+ );
+
+ // This is a cheap way to check if killPods was called
+ expect(mockPodGet).toHaveBeenCalled();
+
+ // Restore the original value for other tests
+ UDSConfig.isAmbientDeployed = true;
+ });
+
+ test("should handle namespace without metadata.labels", async () => {
+ mockGet.mockResolvedValue({
+ metadata: {
+ name: "test-ns",
+ annotations: {},
+ },
+ });
+
+ await enableIstio({
+ metadata: {
+ name: "test-pkg",
+ namespace: "test-ns",
+ },
+ spec: {
+ network: {
+ serviceMesh: {
+ mode: Mode.Sidecar,
+ },
+ },
+ },
+ } as UDSPackage);
+
+ expect(mockApply).toHaveBeenCalled();
+ const applyCall = mockApply.mock.calls[0][0];
+ expect(applyCall.metadata.labels).toHaveProperty("istio-injection", "enabled");
+ });
+
+ test("should handle namespace without metadata.annotations", async () => {
+ mockGet.mockResolvedValue({
+ metadata: {
+ name: "test-ns",
+ labels: {},
+ },
+ });
+
+ await enableIstio({
+ metadata: {
+ name: "test-pkg",
+ namespace: "test-ns",
+ },
+ spec: {
+ network: {
+ serviceMesh: {
+ mode: Mode.Sidecar,
+ },
+ },
+ },
+ } as UDSPackage);
+
+ expect(mockApply).toHaveBeenCalled();
+ const applyCall = mockApply.mock.calls[0][0];
+ expect(applyCall.metadata.annotations["uds.dev/original-istio-state"]).toBe("none");
+ });
+});
+
+describe("cleanupNamespace", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (K8s as jest.Mock).mockImplementation(resourceKind => {
+ if (resourceKind === kind.Pod) {
+ return { InNamespace: jest.fn().mockReturnValue({ Get: mockPodGet }) };
+ }
+ if (resourceKind === kind.Namespace) {
+ return { Get: mockGet, Apply: mockApply };
+ }
+ if (resourceKind === UDSPackage) {
+ return { InNamespace: jest.fn().mockReturnValue({ Get: mockPackageGet }) };
+ }
+ return { Get: jest.fn() };
+ });
+ });
+
+ test("package missing metadata", async () => {
+ const pkg: UDSPackage = { metadata: { name: "test-pkg" } };
+
+ try {
+ await cleanupNamespace(pkg);
+ // Fail test if above expression doesn't throw anything.
+ expect(true).toBe(false);
+ } catch (e) {
+ expect(e).toBeInstanceOf(Error);
+ }
+ });
+
+ test("restores none istio mode", async () => {
+ mockGet.mockResolvedValue({
+ metadata: {
+ labels: { "istio-injection": "enabled" },
+ annotations: {
+ "uds.dev/pkg-test-pkg": "true",
+ "uds.dev/original-istio-state": IstioState.None,
+ },
+ },
+ });
+ const pkg: UDSPackage = { metadata: { namespace: "test-ns", name: "test-pkg" } };
+
+ await cleanupNamespace(pkg);
+
+ expect(mockApply).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({ labels: {}, annotations: {} }),
+ }),
+ { force: true },
+ );
+
+ // This is a cheap way to check if killPods was called
+ expect(mockPodGet).toHaveBeenCalled();
+ });
+
+ test("restores sidecar istio mode", async () => {
+ mockGet.mockResolvedValue({
+ metadata: {
+ labels: { "istio.io/dataplane-mode": "ambient" },
+ annotations: {
+ "uds.dev/pkg-test-pkg": "true",
+ "uds.dev/original-istio-state": IstioState.Sidecar,
+ },
+ },
+ });
+ const pkg: UDSPackage = { metadata: { namespace: "test-ns", name: "test-pkg" } };
+
+ await cleanupNamespace(pkg);
+
+ expect(mockApply).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ labels: { "istio-injection": "enabled" },
+ annotations: {},
+ }),
+ }),
+ { force: true },
+ );
+
+ // This is a cheap way to check if killPods was called
+ expect(mockPodGet).toHaveBeenCalled();
+ });
+
+ test("restores ambient istio mode", async () => {
+ mockGet.mockResolvedValue({
+ metadata: {
+ labels: { "istio-injection": "enabled" },
+ annotations: {
+ "uds.dev/pkg-test-pkg": "true",
+ "uds.dev/original-istio-state": IstioState.Ambient,
+ },
+ },
+ });
+ const pkg: UDSPackage = { metadata: { namespace: "test-ns", name: "test-pkg" } };
+
+ await cleanupNamespace(pkg);
+
+ expect(mockApply).toHaveBeenCalledWith(
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ labels: { "istio.io/dataplane-mode": "ambient" },
+ annotations: {},
+ }),
+ }),
+ { force: true },
+ );
+
+ // This is a cheap way to check if killPods was called
+ expect(mockPodGet).toHaveBeenCalled();
+ });
+
+ test("should handle namespace without metadata.labels during cleanup", async () => {
+ mockGet.mockResolvedValue({
+ metadata: {
+ name: "test-ns",
+ annotations: {
+ "uds.dev/istio-original-state": IstioState.None,
+ "uds.dev/pkg-test-pkg": IstioState.Sidecar,
+ },
+ },
+ });
+
+ await cleanupNamespace({
+ metadata: {
+ name: "test-pkg",
+ namespace: "test-ns",
+ },
+ } as UDSPackage);
+
+ expect(mockApply).toHaveBeenCalled();
+ const applyCall = mockApply.mock.calls[0][0];
+ expect(applyCall.metadata).toHaveProperty("labels");
+ });
+
+ test("should handle namespace without metadata.annotations during cleanup", async () => {
+ mockGet.mockResolvedValue({
+ metadata: {
+ name: "test-ns",
+ labels: {
+ "istio-injection": "enabled",
+ },
+ },
+ });
+
+ await cleanupNamespace({
+ metadata: {
+ name: "test-pkg",
+ namespace: "test-ns",
+ },
+ } as UDSPackage);
+
+ // Should not throw an error and should attempt to update
+ expect(mockApply).toHaveBeenCalled();
+ });
+
+ test("should not modify istio labels when other packages exist", async () => {
+ mockGet.mockResolvedValue({
+ metadata: {
+ name: "test-ns",
+ labels: {
+ "istio-injection": "enabled",
+ },
+ annotations: {
+ "uds.dev/pkg-test-pkg": "true",
+ "uds.dev/pkg-other-pkg": "true",
+ "uds.dev/original-istio-state": "none",
+ },
+ },
+ });
+
+ await cleanupNamespace({
+ metadata: {
+ name: "test-pkg",
+ namespace: "test-ns",
+ },
+ } as UDSPackage);
+
+ expect(mockApply).toHaveBeenCalled();
+ const applyCall = mockApply.mock.calls[0][0];
+
+ // Verify that the istio-injection label is still present
+ expect(applyCall.metadata.labels["istio-injection"]).toBe("enabled");
+
+ // Verify that the original-istio-state annotation is still present
+ expect(applyCall.metadata.annotations["uds.dev/original-istio-state"]).toBe("none");
+
+ // Verify that only the specific package annotation was removed
+ expect(applyCall.metadata.annotations["uds.dev/pkg-test-pkg"]).toBeUndefined();
+ expect(applyCall.metadata.annotations["uds.dev/pkg-other-pkg"]).toBe("true");
+ });
+});
+
+describe("killPods", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (K8s as jest.Mock).mockImplementation(resourceKind => {
+ if (resourceKind === kind.Pod) {
+ return {
+ InNamespace: jest.fn().mockReturnValue({ Get: mockPodGet }),
+ Delete: mockPodDelete,
+ };
+ }
+ });
+ });
+
+ test("no pods to kill", async () => {
+ mockPodGet.mockResolvedValue({ items: [] });
+ await killPods("test-ns", true);
+ expect(mockPodDelete).not.toHaveBeenCalled();
+ });
+
+ test("skip pods with deletion timestamp", async () => {
+ mockPodGet.mockResolvedValue({
+ items: [
+ {
+ metadata: {
+ name: "pod-1",
+ deletionTimestamp: "2021-01-01T00:00:00Z",
+ },
+ spec: {
+ containers: [{ name: "istio-proxy" }],
+ },
+ },
+ ],
+ });
+ await killPods("test-ns", true);
+ expect(mockPodDelete).not.toHaveBeenCalled();
+ });
+
+ test("skip pods that already have sidecar when enabling", async () => {
+ mockPodGet.mockResolvedValue({
+ items: [
+ {
+ metadata: { name: "pod-1" },
+ spec: {
+ containers: [{ name: "istio-proxy" }],
+ },
+ },
+ ],
+ });
+ await killPods("test-ns", true);
+ expect(mockPodDelete).not.toHaveBeenCalled();
+ });
+
+ test("skip pods that have sidecar in initContainers when enabling", async () => {
+ mockPodGet.mockResolvedValue({
+ items: [
+ {
+ metadata: { name: "pod-1" },
+ spec: {
+ containers: [{ name: "app" }],
+ initContainers: [{ name: "istio-proxy" }],
+ },
+ },
+ ],
+ });
+ await killPods("test-ns", true);
+ expect(mockPodDelete).not.toHaveBeenCalled();
+ });
+
+ test("skip pods that don't have sidecar when disabling", async () => {
+ mockPodGet.mockResolvedValue({
+ items: [
+ {
+ metadata: { name: "pod-1" },
+ spec: {
+ containers: [{ name: "app" }],
+ },
+ },
+ ],
+ });
+ await killPods("test-ns", false);
+ expect(mockPodDelete).not.toHaveBeenCalled();
+ });
+
+ test("kill pods that don't have sidecar when enabling", async () => {
+ mockPodGet.mockResolvedValue({
+ items: [
+ {
+ metadata: { name: "pod-1" },
+ spec: {
+ containers: [{ name: "app" }],
+ },
+ },
+ ],
+ });
+ await killPods("test-ns", true);
+ expect(mockPodDelete).toHaveBeenCalledTimes(1);
+ });
+
+ test("kill pods that have sidecar when disabling", async () => {
+ mockPodGet.mockResolvedValue({
+ items: [
+ {
+ metadata: { name: "pod-1" },
+ spec: {
+ containers: [{ name: "istio-proxy" }],
+ },
+ },
+ ],
+ });
+ await killPods("test-ns", false);
+ expect(mockPodDelete).toHaveBeenCalledTimes(1);
+ });
+
+ test("kill pods in reverse order for statefulsets", async () => {
+ // Mock the K8s API responses
+ mockPodGet.mockResolvedValueOnce({
+ items: [
+ {
+ metadata: {
+ name: "pod-0",
+ ownerReferences: [
+ {
+ kind: "StatefulSet",
+ uid: "owner-1",
+ controller: true,
+ },
+ ],
+ },
+ spec: {
+ containers: [{ name: "istio-proxy" }],
+ },
+ },
+ {
+ metadata: {
+ name: "pod-1",
+ ownerReferences: [
+ {
+ kind: "StatefulSet",
+ uid: "owner-1",
+ controller: true,
+ },
+ ],
+ },
+ spec: {
+ containers: [{ name: "istio-proxy" }],
+ },
+ },
+ ],
+ });
+
+ // Call the function under test
+ await killPods("test-ns", false);
+
+ // Verify the pods were deleted in reverse order
+ expect(mockPodDelete).toHaveBeenCalledTimes(2);
+ expect(mockPodDelete.mock.calls[0][0].metadata.name).toBe("pod-1");
+ expect(mockPodDelete.mock.calls[1][0].metadata.name).toBe("pod-0");
+ });
+
+ test("handles non-StatefulSet pods correctly", async () => {
+ mockPodGet.mockResolvedValue({
+ items: [
+ {
+ metadata: {
+ name: "pod-1",
+ ownerReferences: [{ kind: "Deployment", controller: true, uid: "owner-1" }],
+ },
+ spec: {
+ containers: [{ name: "istio-proxy" }],
+ },
+ },
+ {
+ metadata: {
+ name: "pod-2",
+ ownerReferences: [{ kind: "Deployment", controller: true, uid: "owner-1" }],
+ },
+ spec: {
+ containers: [{ name: "istio-proxy" }],
+ },
+ },
+ ],
+ });
+ await killPods("test-ns", false);
+ expect(mockPodDelete).toHaveBeenCalledTimes(2);
+ // For non-StatefulSet pods, the order should be preserved (not reversed)
+ expect(mockPodDelete.mock.calls[0][0].metadata.name).toBe("pod-1");
+ expect(mockPodDelete.mock.calls[1][0].metadata.name).toBe("pod-2");
+ });
+
+ test("handles pods without ownerReferences correctly", async () => {
+ mockPodGet.mockResolvedValue({
+ items: [
+ {
+ metadata: {
+ name: "pod-1",
+ // No ownerReferences
+ },
+ spec: {
+ containers: [{ name: "istio-proxy" }],
+ },
+ },
+ ],
+ });
+ await killPods("test-ns", false);
+ expect(mockPodDelete).toHaveBeenCalledTimes(1);
+ });
+
+ test("handles pods with non-controller ownerReferences correctly", async () => {
+ mockPodGet.mockResolvedValue({
+ items: [
+ {
+ metadata: {
+ name: "pod-1",
+ ownerReferences: [{ kind: "StatefulSet", controller: false, uid: "owner-1" }],
+ },
+ spec: {
+ containers: [{ name: "istio-proxy" }],
+ },
+ },
+ ],
+ });
+ await killPods("test-ns", false);
+ expect(mockPodDelete).toHaveBeenCalledTimes(1);
+ });
+
+ test("handles pods with undefined metadata correctly", async () => {
+ mockPodGet.mockResolvedValue({
+ items: [
+ {
+ // No metadata
+ spec: {
+ containers: [{ name: "istio-proxy" }],
+ },
+ },
+ ],
+ });
+ await killPods("test-ns", false);
+ expect(mockPodDelete).toHaveBeenCalledTimes(1);
+ });
+
+ test("sorts statefulset pods in reverse order before deletion", async () => {
+ // Mock the K8s API responses
+ mockPodGet.mockResolvedValueOnce({
+ items: [
+ {
+ metadata: {
+ name: "pod-1",
+ ownerReferences: [
+ {
+ kind: "StatefulSet",
+ uid: "owner-1",
+ controller: true,
+ },
+ ],
+ },
+ spec: {
+ containers: [{ name: "istio-proxy" }],
+ },
+ },
+ {
+ metadata: {
+ name: "pod-0",
+ ownerReferences: [
+ {
+ kind: "StatefulSet",
+ uid: "owner-1",
+ controller: true,
+ },
+ ],
+ },
+ spec: {
+ containers: [{ name: "istio-proxy" }],
+ },
+ },
+ ],
+ });
+
+ // Call the function under test
+ await killPods("test-ns", false);
+
+ // Verify the pods were deleted in reverse order
+ expect(mockPodDelete).toHaveBeenCalledTimes(2);
+ expect(mockPodDelete.mock.calls[0][0].metadata.name).toBe("pod-1");
+ expect(mockPodDelete.mock.calls[1][0].metadata.name).toBe("pod-0");
+ });
+});
+
+describe("getCurrentIstioState", () => {
+ test("returns Sidecar when istio-injection is enabled", () => {
+ const labels = { "istio-injection": "enabled", "other-label": "value" };
+ expect(getCurrentIstioState(labels)).toBe(IstioState.Sidecar);
+ });
+
+ test("returns Ambient when istio.io/dataplane-mode is ambient", () => {
+ const labels = { "istio.io/dataplane-mode": "ambient", "other-label": "value" };
+ expect(getCurrentIstioState(labels)).toBe(IstioState.Ambient);
+ });
+
+ test("returns None when no Istio labels are present", () => {
+ const labels = { "other-label": "value" };
+ expect(getCurrentIstioState(labels)).toBe(IstioState.None);
+ });
+
+ test("prioritizes Sidecar over Ambient when both labels are present", () => {
+ const labels = {
+ "istio-injection": "enabled",
+ "istio.io/dataplane-mode": "ambient",
+ "other-label": "value",
+ };
+ expect(getCurrentIstioState(labels)).toBe(IstioState.Sidecar);
+ });
+});
+
+describe("getIstioLabels", () => {
+ test("sets Sidecar mode labels correctly", () => {
+ const labels = { "other-label": "value" };
+ const result = getIstioLabels(labels, IstioState.Sidecar, IstioState.None);
+
+ expect(result.labels).toEqual({
+ "istio-injection": "enabled",
+ "other-label": "value",
+ });
+ expect(result.shouldRestartPods).toBe(true);
+ });
+
+ test("sets Ambient mode labels correctly", () => {
+ const labels = { "other-label": "value" };
+ const result = getIstioLabels(labels, IstioState.Ambient, IstioState.None);
+
+ expect(result.labels).toEqual({
+ "istio.io/dataplane-mode": "ambient",
+ "other-label": "value",
+ });
+ expect(result.shouldRestartPods).toBe(false);
+ });
+
+ test("sets None mode labels correctly", () => {
+ const labels = {
+ "istio-injection": "enabled",
+ "other-label": "value",
+ };
+ const result = getIstioLabels(labels, IstioState.None, IstioState.Sidecar);
+
+ expect(result.labels).toEqual({
+ "other-label": "value",
+ });
+ expect(result.shouldRestartPods).toBe(true);
+ });
+
+ test("doesn't set shouldRestartPods when no change is needed", () => {
+ const labels = { "istio-injection": "enabled", "other-label": "value" };
+ const result = getIstioLabels(labels, IstioState.Sidecar, IstioState.Sidecar);
+
+ expect(result.labels).toEqual({
+ "istio-injection": "enabled",
+ "other-label": "value",
+ });
+ expect(result.shouldRestartPods).toBe(false);
+ });
+
+ test("sets shouldRestartPods when changing from Sidecar to Ambient", () => {
+ const labels = { "istio-injection": "enabled", "other-label": "value" };
+ const result = getIstioLabels(labels, IstioState.Ambient, IstioState.Sidecar);
+
+ expect(result.labels).toEqual({
+ "istio.io/dataplane-mode": "ambient",
+ "other-label": "value",
+ });
+ expect(result.shouldRestartPods).toBe(true);
+ });
+
+ test("sets shouldRestartPods when changing from Ambient to Sidecar", () => {
+ const labels = { "istio.io/dataplane-mode": "ambient", "other-label": "value" };
+ const result = getIstioLabels(labels, IstioState.Sidecar, IstioState.Ambient);
+
+ expect(result.labels).toEqual({
+ "istio-injection": "enabled",
+ "other-label": "value",
+ });
+ expect(result.shouldRestartPods).toBe(true);
+ });
+});
+
+describe("applyNamespaceUpdates", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (K8s as jest.Mock).mockImplementation(resourceKind => {
+ if (resourceKind === kind.Namespace) {
+ return { Apply: mockApply };
+ }
+ return { Get: jest.fn() };
+ });
+ });
+
+ test("applies updates when labels change", async () => {
+ const namespace = "test-ns";
+ const labels = { "new-label": "value" };
+ const annotations = { annotation: "value" };
+ const originalLabels = { "old-label": "value" };
+ const originalAnnotations = { annotation: "value" };
+
+ const result = await applyNamespaceUpdates(
+ namespace,
+ labels,
+ annotations,
+ originalLabels,
+ originalAnnotations,
+ );
+
+ expect(result).toBe(true);
+ expect(mockApply).toHaveBeenCalledWith(
+ {
+ metadata: {
+ name: namespace,
+ labels,
+ annotations,
+ },
+ },
+ { force: true },
+ );
+ });
+
+ test("applies updates when annotations change", async () => {
+ const namespace = "test-ns";
+ const labels = { label: "value" };
+ const annotations = { "new-annotation": "value" };
+ const originalLabels = { label: "value" };
+ const originalAnnotations = { "old-annotation": "value" };
+
+ const result = await applyNamespaceUpdates(
+ namespace,
+ labels,
+ annotations,
+ originalLabels,
+ originalAnnotations,
+ );
+
+ expect(result).toBe(true);
+ expect(mockApply).toHaveBeenCalledWith(
+ {
+ metadata: {
+ name: namespace,
+ labels,
+ annotations,
+ },
+ },
+ { force: true },
+ );
+ });
+
+ test("doesn't apply updates when nothing changes", async () => {
+ const namespace = "test-ns";
+ const labels = { label: "value" };
+ const annotations = { annotation: "value" };
+ const originalLabels = { label: "value" };
+ const originalAnnotations = { annotation: "value" };
+
+ const result = await applyNamespaceUpdates(
+ namespace,
+ labels,
+ annotations,
+ originalLabels,
+ originalAnnotations,
+ );
+
+ expect(result).toBe(false);
+ expect(mockApply).not.toHaveBeenCalled();
+ });
+
+ test("uses custom log message when provided", async () => {
+ const namespace = "test-ns";
+ const labels = { "new-label": "value" };
+ const annotations = { annotation: "value" };
+ const originalLabels = { "old-label": "value" };
+ const originalAnnotations = { annotation: "value" };
+ const logMessage = "Custom log message";
+
+ // We can't easily test the log message, but we can verify the function behavior
+ const result = await applyNamespaceUpdates(
+ namespace,
+ labels,
+ annotations,
+ originalLabels,
+ originalAnnotations,
+ logMessage,
+ );
+
+ expect(result).toBe(true);
+ expect(mockApply).toHaveBeenCalled();
+ });
+});
diff --git a/src/pepr/operator/controllers/istio/namespace.ts b/src/pepr/operator/controllers/istio/namespace.ts
new file mode 100644
index 0000000000..75553a9b7a
--- /dev/null
+++ b/src/pepr/operator/controllers/istio/namespace.ts
@@ -0,0 +1,317 @@
+/**
+ * Copyright 2025 Defense Unicorns
+ * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial
+ */
+
+import { K8s, kind, R } from "pepr";
+import { UDSConfig } from "../../../config";
+import { Component, setupLogger } from "../../../logger";
+import { UDSPackage } from "../../crd";
+import { Mode } from "../../crd/generated/package-v1alpha1";
+import { writeEvent } from "../../reconcilers";
+
+// configure subproject logger
+const log = setupLogger(Component.OPERATOR_ISTIO);
+
+const INJECTION_LABEL = "istio-injection";
+const AMBIENT_LABEL = "istio.io/dataplane-mode";
+const ISTIO_STATE_ANNOTATION = "uds.dev/original-istio-state";
+
+export enum IstioState {
+ Sidecar = Mode.Sidecar,
+ Ambient = Mode.Ambient,
+ None = "none",
+}
+
+/**
+ * Determine the proper istio mode (ambient or sidecar) and enable for the package namespace
+ *
+ * @param pkg
+ * @returns string mode
+ */
+export async function enableIstio(pkg: UDSPackage) {
+ // This should be impossible in practice but added here so that we can assume these are defined in the rest of the function
+ // The types for the GenericKind here allow metadata to be undefined technically
+ if (!pkg.metadata?.namespace || !pkg.metadata.name) {
+ throw new Error(`Invalid Package definition, missing namespace or name`);
+ }
+
+ const sourceNS = await K8s(kind.Namespace).Get(pkg.metadata.namespace);
+ const labels = { ...(sourceNS.metadata?.labels || {}) };
+ const annotations = { ...(sourceNS.metadata?.annotations || {}) };
+ const pkgKey = `uds.dev/pkg-${pkg.metadata.name}`;
+ const currentIstioState = getCurrentIstioState(labels);
+
+ // Mark the original namespace istio setting for if packages are removed
+ if (!annotations[ISTIO_STATE_ANNOTATION]) {
+ annotations[ISTIO_STATE_ANNOTATION] = currentIstioState;
+ }
+
+ let targetIstioState = IstioState.None;
+ annotations[pkgKey] = "true";
+
+ // Handle labels based on ambient opt-in or sidecar default
+ if (pkg.spec?.network?.serviceMesh?.mode === Mode.Ambient) {
+ // Check if ambient mode is available
+ if (!UDSConfig.isAmbientDeployed) {
+ // Ambient mode requested but not available, fall back to sidecar mode
+ log.warn(
+ `Ambient mode requested for package ${pkg.metadata.name} but Ambient is not deployed. Falling back to sidecar mode.`,
+ );
+ await writeEvent(pkg, {
+ message:
+ "Ambient mode requested but Ambient is not deployed. Falling back to sidecar mode.",
+ reason: "AmbientUnavailable",
+ type: "Warning",
+ });
+
+ // Fall back to sidecar mode
+ targetIstioState = IstioState.Sidecar;
+ } else {
+ // Ambient mode is available and requested
+ targetIstioState = IstioState.Ambient;
+ }
+ } else {
+ // Sidecar mode requested/by default
+ targetIstioState = IstioState.Sidecar;
+ }
+
+ const result = getIstioLabels(labels, targetIstioState, currentIstioState);
+
+ // Apply namespace updates and restart pods if needed
+ await applyNamespaceUpdates(
+ pkg.metadata.namespace,
+ result.labels,
+ annotations,
+ sourceNS.metadata?.labels,
+ sourceNS.metadata?.annotations,
+ );
+
+ await restartPodsIfNeeded(pkg.metadata.namespace, result.shouldRestartPods, targetIstioState);
+}
+
+/**
+ * Cleanup the namespace by removing Istio labels if there are no remaining packages
+ * Or adjust the Istio mode if existing packages change it
+ *
+ * @param pkg the package to cleanup
+ */
+export async function cleanupNamespace(pkg: UDSPackage) {
+ if (!pkg.metadata?.namespace || !pkg.metadata.name) {
+ throw new Error(`Invalid Package definition, missing namespace or name`);
+ }
+
+ const sourceNS = await K8s(kind.Namespace).Get(pkg.metadata.namespace);
+ const labels = { ...(sourceNS.metadata?.labels || {}) };
+ const annotations = { ...(sourceNS.metadata?.annotations || {}) };
+ const currentState = getCurrentIstioState(labels);
+ const originalIstioState = annotations[ISTIO_STATE_ANNOTATION] as IstioState;
+
+ // Remove the package annotation
+ delete annotations[`uds.dev/pkg-${pkg.metadata.name}`];
+
+ // Check if there are any other package annotations
+ // Backwards compatibility for multiple package CRs in a single namespace
+ const hasOtherPackages = Object.keys(annotations).some(key => key.startsWith("uds.dev/pkg-"));
+
+ // Only modify Istio labels if this is the last package
+ let result;
+ if (hasOtherPackages) {
+ // Keep existing labels if other packages are still present, don't cycle pods/change istio state
+ result = { labels, shouldRestartPods: false };
+ } else {
+ // Set labels based on the original state and determine if pods need to be restarted
+ result = getIstioLabels(labels, originalIstioState, currentState);
+
+ // Delete the annotation since we're restoring to original state
+ delete annotations[ISTIO_STATE_ANNOTATION];
+ }
+
+ // Apply the updated Namespace
+ await applyNamespaceUpdates(
+ pkg.metadata.namespace,
+ result.labels,
+ annotations,
+ sourceNS.metadata?.labels,
+ sourceNS.metadata?.annotations,
+ `Updating namespace ${pkg.metadata.namespace}, removing ${pkg.metadata.name} state.`,
+ );
+
+ // Restart pods if needed
+ await restartPodsIfNeeded(pkg.metadata.namespace, result.shouldRestartPods, originalIstioState);
+}
+
+/**
+ * Forces deletion of pods with the incorrect istio sidecar state
+ *
+ * @param ns The namespace to target
+ * @param wantSidecar Whether injection is being enabled
+ */
+export async function killPods(ns: string, wantSidecar: boolean) {
+ // Get all pods in the namespace
+ const pods = await K8s(kind.Pod).InNamespace(ns).Get();
+ const groups: Record = {};
+
+ // Group the pods by owner UID
+ for (const pod of pods.items) {
+ // Ignore pods that already have a deletion timestamp
+ if (pod.metadata?.deletionTimestamp) {
+ log.debug(`Ignoring Pod ${ns}/${pod.metadata?.name}, already being deleted`);
+ continue;
+ }
+
+ // Checks both container (haven't switched to native sidecars yet) and initContainers for native sidecars
+ const foundSidecar =
+ pod.spec?.containers?.some(c => c.name === "istio-proxy") ||
+ pod.spec?.initContainers?.some(c => c.name === "istio-proxy");
+
+ // If enabling injection, ignore pods that already have the istio sidecar
+ if (wantSidecar && foundSidecar) {
+ log.debug(`Ignoring Pod ${ns}/${pod.metadata?.name}, already has sidecar`);
+ continue;
+ }
+
+ // If disabling injection, ignore pods that don't have the istio sidecar
+ if (!wantSidecar && !foundSidecar) {
+ log.debug(`Ignoring Pod ${ns}/${pod.metadata?.name}, injection disabled`);
+ continue;
+ }
+
+ // Get the UID of the owner of the pod or default to "other" (shouldn't happen)
+ const controlledBy = pod.metadata?.ownerReferences?.find(ref => ref.controller)?.uid || "other";
+ groups[controlledBy] = groups[controlledBy] || [];
+ log.debug(`Adding Pod ${ns}/${pod.metadata?.name} to ${controlledBy} deletion list.`);
+ groups[controlledBy].push(pod);
+ }
+
+ // Delete each group of pods
+ for (const group of Object.values(groups)) {
+ // If this is a statefulset, delete the pods in reverse name order
+ if (group[0].metadata?.ownerReferences?.find(ref => ref.kind === "StatefulSet")) {
+ group.sort((a, b) => (b.metadata?.name || "").localeCompare(a.metadata?.name || ""));
+ }
+
+ for (const pod of group) {
+ const action = wantSidecar ? "enable" : "remove";
+ log.info(`Deleting pod ${ns}/${pod.metadata?.name} to ${action} the istio sidecar`);
+ await K8s(kind.Pod).Delete(pod);
+ }
+ }
+}
+
+/**
+ * Get the current Istio state of a namespace based on its labels
+ *
+ * @param labels The namespace labels
+ * @returns The current Istio state
+ */
+export function getCurrentIstioState(labels: Record): IstioState {
+ return labels[INJECTION_LABEL] === "enabled"
+ ? IstioState.Sidecar
+ : labels[AMBIENT_LABEL] === "ambient"
+ ? IstioState.Ambient
+ : IstioState.None;
+}
+
+/**
+ * Apply namespace updates if there are changes to labels or annotations
+ *
+ * @param namespace The namespace name
+ * @param labels The updated labels
+ * @param annotations The updated annotations
+ * @param originalLabels The original labels
+ * @param originalAnnotations The original annotations
+ * @param logMessage Optional log message
+ * @returns Whether updates were applied
+ */
+export async function applyNamespaceUpdates(
+ namespace: string,
+ labels: Record,
+ annotations: Record,
+ originalLabels: Record | undefined,
+ originalAnnotations: Record | undefined,
+ logMessage?: string,
+): Promise {
+ const updatedNamespace = {
+ metadata: {
+ name: namespace,
+ labels,
+ annotations,
+ },
+ };
+
+ if (!R.equals(originalLabels, labels) || !R.equals(originalAnnotations, annotations)) {
+ log.debug(logMessage || `Applying updates to namespace ${namespace}.`);
+ await K8s(kind.Namespace).Apply(updatedNamespace, { force: true });
+ return true;
+ } else {
+ log.debug(`No namespace updates needed for ${namespace}.`);
+ return false;
+ }
+}
+
+/**
+ * Restart pods if the Istio state has changed
+ *
+ * @param namespace The namespace name
+ * @param shouldRestart Whether pods should be restarted
+ * @param istioState The target Istio state
+ */
+async function restartPodsIfNeeded(
+ namespace: string,
+ shouldRestart: boolean,
+ targetIstioState: IstioState,
+): Promise {
+ if (shouldRestart) {
+ log.debug(
+ `Restarting pods in ${namespace} due to configuration changes (switching to ${targetIstioState} Istio state)`,
+ );
+ if (targetIstioState === IstioState.Sidecar) {
+ await killPods(namespace, true);
+ } else if (targetIstioState === IstioState.Ambient || targetIstioState === IstioState.None) {
+ await killPods(namespace, false);
+ }
+ }
+}
+
+/**
+ * Gets the appropriate Istio labels based on the target state and determines if pods need to be restarted
+ *
+ * @param labels The current labels
+ * @param targetState The target Istio state
+ * @param currentState The current Istio state
+ * @returns Updated labels and whether pods should be restarted
+ */
+export function getIstioLabels(
+ labels: Record,
+ targetState: IstioState,
+ currentState: IstioState,
+): { labels: Record; shouldRestartPods: boolean } {
+ let shouldRestartPods = false;
+
+ if (targetState === IstioState.Sidecar) {
+ labels[INJECTION_LABEL] = "enabled";
+ delete labels[AMBIENT_LABEL];
+ // Add sidecar if not present or coming from ambient
+ if (currentState !== IstioState.Sidecar) {
+ shouldRestartPods = true;
+ }
+ } else if (targetState === IstioState.Ambient) {
+ labels[AMBIENT_LABEL] = "ambient";
+ delete labels[INJECTION_LABEL];
+ // Remove sidecar if present
+ if (currentState === IstioState.Sidecar) {
+ shouldRestartPods = true;
+ }
+ } else {
+ // None state - remove all Istio labels
+ delete labels[INJECTION_LABEL];
+ delete labels[AMBIENT_LABEL];
+ // Remove sidecar if present
+ if (currentState === IstioState.Sidecar) {
+ shouldRestartPods = true;
+ }
+ }
+
+ return { labels, shouldRestartPods };
+}
diff --git a/src/pepr/operator/controllers/network/generators/ambientHealthprobes.ts b/src/pepr/operator/controllers/network/generators/ambientHealthprobes.ts
new file mode 100644
index 0000000000..22a98c7676
--- /dev/null
+++ b/src/pepr/operator/controllers/network/generators/ambientHealthprobes.ts
@@ -0,0 +1,15 @@
+/**
+ * Copyright 2024 Defense Unicorns
+ * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial
+ */
+
+import { Direction } from "../../../crd";
+import { generate } from "../generate";
+
+// See https://istio.io/latest/docs/ambient/usage/networkpolicy/#ambient-health-probes-and-kubernetes-networkpolicy
+export const allowAmbientHealthprobes = (namespace: string) =>
+ generate(namespace, {
+ direction: Direction.Ingress,
+ description: "Ambient Healthprobes",
+ remoteCidr: "169.254.7.127/32",
+ });
diff --git a/src/pepr/operator/controllers/network/policies.ts b/src/pepr/operator/controllers/network/policies.ts
index 6aaa91fe83..5eaa4145f1 100644
--- a/src/pepr/operator/controllers/network/policies.ts
+++ b/src/pepr/operator/controllers/network/policies.ts
@@ -8,17 +8,19 @@ import { K8s, kind } from "pepr";
import { UDSConfig } from "../../../config";
import { Component, setupLogger } from "../../../logger";
import { Allow, Direction, Gateway, RemoteGenerated, UDSPackage } from "../../crd";
+import { IstioState } from "../istio/namespace";
import { getOwnerRef, purgeOrphans, sanitizeResourceName } from "../utils";
import { allowEgressDNS } from "./defaults/allow-egress-dns";
import { allowEgressIstiod } from "./defaults/allow-egress-istiod";
import { allowIngressSidecarMonitoring } from "./defaults/allow-ingress-sidecar-monitoring";
import { defaultDenyAll } from "./defaults/default-deny-all";
import { generate } from "./generate";
+import { allowAmbientHealthprobes } from "./generators/ambientHealthprobes";
// configure subproject logger
const log = setupLogger(Component.OPERATOR_NETWORK);
-export async function networkPolicies(pkg: UDSPackage, namespace: string) {
+export async function networkPolicies(pkg: UDSPackage, namespace: string, istioMode: string) {
const customPolicies = pkg.spec?.network?.allow ?? [];
const pkgName = pkg.metadata!.name!;
@@ -34,12 +36,19 @@ export async function networkPolicies(pkg: UDSPackage, namespace: string) {
// Allow DNS lookups
allowEgressDNS(namespace),
-
- // Istio rules
- allowEgressIstiod(namespace),
- allowIngressSidecarMonitoring(namespace),
];
+ // Istio rules for sidecars
+ if (istioMode === IstioState.Sidecar) {
+ policies.push(allowEgressIstiod(namespace));
+ policies.push(allowIngressSidecarMonitoring(namespace));
+ }
+
+ // Istio rules for ambient mode
+ if (istioMode === IstioState.Ambient) {
+ policies.push(allowAmbientHealthprobes(namespace));
+ }
+
// Process custom policies
for (const policy of customPolicies) {
const generatedPolicy = generate(namespace, policy);
@@ -142,6 +151,31 @@ export async function networkPolicies(pkg: UDSPackage, namespace: string) {
policy.metadata.name = `allow-${pkgName}-${policy.metadata.name}`;
}
+ // Loop through all ports in ingress/egress policies and add port 15008 for ztunnel
+ if (policy.spec?.ingress) {
+ for (const ingress of policy.spec.ingress) {
+ // Only add the port if there is a port restriction
+ if (ingress.ports && ingress.ports.some(port => port.protocol !== "UDP")) {
+ ingress.ports.push({ port: 15008 });
+ }
+ }
+ } else if (policy.spec?.egress) {
+ for (const egress of policy.spec.egress) {
+ // Don't add port 15008 for egress destinations that we know are not in-mesh or not in-cluster
+ if (
+ policy.metadata?.labels?.["uds/generated"] === RemoteGenerated.KubeNodes ||
+ policy.metadata?.labels?.["uds/generated"] === RemoteGenerated.KubeAPI ||
+ policy.metadata?.labels?.["uds/generated"] === RemoteGenerated.CloudMetadata
+ ) {
+ continue;
+ }
+ // Only add the port if there is a port restriction
+ if (egress.ports && egress.ports.some(port => port.protocol !== "UDP")) {
+ egress.ports.push({ port: 15008 });
+ }
+ }
+ }
+
// Ensure the name is a valid resource name
policy.metadata.name = sanitizeResourceName(policy.metadata.name);
diff --git a/src/pepr/operator/crd/generated/package-v1alpha1.ts b/src/pepr/operator/crd/generated/package-v1alpha1.ts
index 2ccb0d9e55..28c7bc0814 100644
--- a/src/pepr/operator/crd/generated/package-v1alpha1.ts
+++ b/src/pepr/operator/crd/generated/package-v1alpha1.ts
@@ -137,6 +137,10 @@ export interface Network {
* Expose a service on an Istio Gateway
*/
expose?: Expose[];
+ /**
+ * Service Mesh configuration for the package
+ */
+ serviceMesh?: ServiceMesh;
}
export interface Allow {
@@ -580,6 +584,24 @@ export interface FluffyURI {
regex?: string;
}
+/**
+ * Service Mesh configuration for the package
+ */
+export interface ServiceMesh {
+ /**
+ * Set the service mesh mode for this package (namespace), defaults to sidecar
+ */
+ mode?: Mode;
+}
+
+/**
+ * Set the service mesh mode for this package (namespace), defaults to sidecar
+ */
+export enum Mode {
+ Ambient = "ambient",
+ Sidecar = "sidecar",
+}
+
export interface Sso {
/**
* Always list this client in the Account UI, even if the user does not have an active
diff --git a/src/pepr/operator/crd/sources/package/v1alpha1.ts b/src/pepr/operator/crd/sources/package/v1alpha1.ts
index a244d7fc7b..74714bfb39 100644
--- a/src/pepr/operator/crd/sources/package/v1alpha1.ts
+++ b/src/pepr/operator/crd/sources/package/v1alpha1.ts
@@ -208,6 +208,19 @@ const expose = {
} as V1JSONSchemaProps,
} as V1JSONSchemaProps;
+const serviceMesh = {
+ description: "Service Mesh configuration for the package",
+ type: "object",
+ properties: {
+ mode: {
+ type: "string",
+ enum: ["sidecar", "ambient"],
+ default: "sidecar",
+ description: "Set the service mesh mode for this package (namespace), defaults to sidecar",
+ },
+ },
+} as V1JSONSchemaProps;
+
const monitor = {
description: "Create Service or Pod Monitor configurations",
type: "array",
@@ -600,6 +613,7 @@ export const v1alpha1: V1CustomResourceDefinitionVersion = {
properties: {
expose,
allow,
+ serviceMesh,
},
},
monitor,
diff --git a/src/pepr/operator/crd/validators/package-validator.spec.ts b/src/pepr/operator/crd/validators/package-validator.spec.ts
index 462b968f22..25e6480299 100644
--- a/src/pepr/operator/crd/validators/package-validator.spec.ts
+++ b/src/pepr/operator/crd/validators/package-validator.spec.ts
@@ -16,6 +16,7 @@ import {
Sso,
UDSPackage,
} from "..";
+import { Mode } from "../generated/package-v1alpha1";
import { validator } from "./package-validator";
const makeMockReq = (
@@ -24,6 +25,7 @@ const makeMockReq = (
allowList: Partial[],
ssoClients: Partial[],
monitorList: Partial[],
+ ambient: boolean = false,
) => {
const defaultPkg: UDSPackage = {
metadata: {
@@ -34,6 +36,9 @@ const makeMockReq = (
network: {
expose: [],
allow: [],
+ serviceMesh: {
+ mode: ambient ? Mode.Ambient : Mode.Sidecar,
+ },
},
sso: [],
monitor: [],
@@ -80,7 +85,7 @@ const makeMockReq = (
} as unknown as PeprValidateRequest;
};
-describe("Test validation of Exemption CRs", () => {
+describe("Test validation of Package CRs", () => {
afterEach(() => {
jest.resetAllMocks();
});
@@ -527,6 +532,26 @@ describe("Test validation of Exemption CRs", () => {
await validator(mockReq);
expect(mockReq.Approve).toHaveBeenCalledTimes(1);
});
+
+ it("denies authservice clients in ambient mode", async () => {
+ const mockReq = makeMockReq(
+ {},
+ [],
+ [],
+ [
+ {
+ clientId: "http://example.com",
+ enableAuthserviceSelector: {
+ app: "foobar",
+ },
+ },
+ ],
+ [],
+ true,
+ );
+ await validator(mockReq);
+ expect(mockReq.Deny).toHaveBeenCalledTimes(1);
+ });
});
describe("Test Allowed SSO Client Attributes", () => {
diff --git a/src/pepr/operator/crd/validators/package-validator.ts b/src/pepr/operator/crd/validators/package-validator.ts
index 33860ec0d1..07be4b68b6 100644
--- a/src/pepr/operator/crd/validators/package-validator.ts
+++ b/src/pepr/operator/crd/validators/package-validator.ts
@@ -10,7 +10,7 @@ import { generateVSName } from "../../controllers/istio/virtual-service";
import { generateMonitorName } from "../../controllers/monitoring/common";
import { generateName } from "../../controllers/network/generate";
import { sanitizeResourceName } from "../../controllers/utils";
-import { Kind } from "../../crd/generated/package-v1alpha1";
+import { Kind, Mode } from "../../crd/generated/package-v1alpha1";
import { migrate } from "../migrate";
const invalidNamespaces = ["kube-system", "kube-public", "_unknown_", "pepr-system"];
@@ -186,6 +186,13 @@ export async function validator(req: PeprValidateRequest) {
`The client ID "${client.clientId}" is invalid as an Authservice client - Authservice does not support client IDs with the ":" character`,
);
}
+ // If this is an authservice client ensure the package is not in ambient mode,
+ // See https://github.com/defenseunicorns/uds-core/issues/1029 and https://github.com/defenseunicorns/uds-core/issues/1200
+ if (client.enableAuthserviceSelector && pkg.spec?.network?.serviceMesh?.mode === Mode.Ambient) {
+ return req.Deny(
+ `The package contains an invalid configuration combination - Authservice clients are not currently supported in ambient mode`,
+ );
+ }
}
const monitors = pkg.spec?.monitor ?? [];
diff --git a/src/pepr/operator/index.ts b/src/pepr/operator/index.ts
index 77ca558283..2ed743f6cb 100644
--- a/src/pepr/operator/index.ts
+++ b/src/pepr/operator/index.ts
@@ -29,13 +29,13 @@ import { validator } from "./crd/validators/package-validator";
import { UDSConfig } from "../config";
import { Component, setupLogger } from "../logger";
import { updateUDSConfig } from "./controllers/config/config";
-import { exemptValidator } from "./crd/validators/exempt-validator";
-import { packageFinalizer, packageReconciler } from "./reconcilers/package-reconciler";
import {
KEYCLOAK_CLIENTS_SECRET_NAME,
KEYCLOAK_CLIENTS_SECRET_NAMESPACE,
updateKeycloakClientsSecret,
} from "./controllers/keycloak/client-secret-sync";
+import { exemptValidator } from "./crd/validators/exempt-validator";
+import { packageFinalizer, packageReconciler } from "./reconcilers/package-reconciler";
// Export the operator capability for registration in the root pepr.ts
export { operator } from "./common";
@@ -103,6 +103,24 @@ When(UDSPackage)
UDSConfig.isIdentityDeployed = false;
});
+// Watch for optional Ambient mode and update config (temporary while ambient components are optional)
+When(a.DaemonSet)
+ .IsCreatedOrUpdated()
+ .InNamespace("istio-system")
+ .WithName("ztunnel")
+ .Watch(() => {
+ log.info("Istio Ambient deployed, operator configured to handle ambient packages.");
+ UDSConfig.isAmbientDeployed = true;
+ });
+When(a.DaemonSet)
+ .IsDeleted()
+ .InNamespace("istio-system")
+ .WithName("ztunnel")
+ .Watch(() => {
+ log.info("Istio Ambient removed, operator will run all packages in sidecar mode.");
+ UDSConfig.isAmbientDeployed = false;
+ });
+
// Watch for changes to the Nodes and update the Node CIDR list
if (!UDSConfig.kubeNodeCidrs) {
When(a.Node).IsCreatedOrUpdated().Reconcile(updateKubeNodesFromCreateUpdate);
diff --git a/src/pepr/operator/reconcilers/package-reconciler.spec.ts b/src/pepr/operator/reconcilers/package-reconciler.spec.ts
index 79b839bdad..fd626dbaec 100644
--- a/src/pepr/operator/reconcilers/package-reconciler.spec.ts
+++ b/src/pepr/operator/reconcilers/package-reconciler.spec.ts
@@ -7,7 +7,7 @@ import { beforeEach, describe, expect, jest, test } from "@jest/globals";
import { K8s, Log } from "pepr";
import { writeEvent } from ".";
-import { cleanupNamespace } from "../controllers/istio/injection";
+import { cleanupNamespace } from "../controllers/istio/namespace";
import { purgeAuthserviceClients } from "../controllers/keycloak/authservice/authservice";
import { purgeSSOClients } from "../controllers/keycloak/client-sync";
import { retryWithDelay } from "../controllers/utils";
@@ -22,7 +22,7 @@ const mockWriteEvent = jest.fn();
jest.mock("kubernetes-fluent-client");
jest.mock("../../config");
-jest.mock("../controllers/istio/injection", () => ({
+jest.mock("../controllers/istio/namespace", () => ({
cleanupNamespace: jest.fn(),
}));
jest.mock("../controllers/keycloak/client-sync", () => ({
diff --git a/src/pepr/operator/reconcilers/package-reconciler.ts b/src/pepr/operator/reconcilers/package-reconciler.ts
index 47e9686f46..79f046d9b7 100644
--- a/src/pepr/operator/reconcilers/package-reconciler.ts
+++ b/src/pepr/operator/reconcilers/package-reconciler.ts
@@ -6,8 +6,8 @@
import { getReadinessConditions, handleFailure, shouldSkip, updateStatus, writeEvent } from ".";
import { UDSConfig } from "../../config";
import { Component, setupLogger } from "../../logger";
-import { cleanupNamespace, enableInjection } from "../controllers/istio/injection";
import { istioResources } from "../controllers/istio/istio-resources";
+import { cleanupNamespace, enableIstio } from "../controllers/istio/namespace";
import {
authservice,
purgeAuthserviceClients,
@@ -20,6 +20,7 @@ import { generateAuthorizationPolicies } from "../controllers/network/authorizat
import { networkPolicies } from "../controllers/network/policies";
import { retryWithDelay } from "../controllers/utils";
import { Phase, UDSPackage } from "../crd";
+import { Mode } from "../crd/generated/package-v1alpha1";
import { migrate } from "../crd/migrate";
// configure subproject logger
@@ -70,13 +71,27 @@ export async function packageReconciler(pkg: UDSPackage) {
try {
await updateStatus(pkg, { phase: Phase.Pending, conditions: getReadinessConditions(false) });
- const netPol = await networkPolicies(pkg, namespace!);
+ // Get the requested service mesh mode, default to sidecar if not specified
+ const requestedMode = pkg.spec?.network?.serviceMesh?.mode || Mode.Sidecar;
+
+ // Check if ambient mode is requested but not available
+ let effectiveMode = requestedMode;
+ if (requestedMode === Mode.Ambient && !UDSConfig.isAmbientDeployed) {
+ log.warn(
+ `Ambient mode requested for package ${name} but Ambient is not deployed. Using sidecar mode for network policies.`,
+ );
+ effectiveMode = Mode.Sidecar;
+ }
+
+ // Pass the effective Istio mode to the networkPolicies function
+ const netPol = await networkPolicies(pkg, namespace!, effectiveMode);
const authPol = await generateAuthorizationPolicies(pkg, namespace!);
let endpoints: string[] = [];
- // Update the namespace to ensure the istio-injection label is set
- await enableInjection(pkg);
+ // Update the namespace to enable the expected Istio mode (sidecar or ambient)
+ // Note: enableIstio will also check UDSConfig.isAmbientDeployed and fall back if needed
+ await enableIstio(pkg);
let ssoClients = new Map();
let authserviceClients: string[] = [];
diff --git a/src/test/chart/templates/package.yaml b/src/test/chart/templates/package.yaml
index c9fa7b778a..c334781922 100644
--- a/src/test/chart/templates/package.yaml
+++ b/src/test/chart/templates/package.yaml
@@ -7,6 +7,16 @@ metadata:
name: podinfo
namespace: podinfo
spec:
+ network:
+ serviceMesh:
+ mode: ambient
+ expose:
+ - service: podinfo
+ selector:
+ app.kubernetes.io/name: podinfo
+ gateway: tenant
+ host: podinfo
+ port: 9898
monitor:
- selector:
app.kubernetes.io/name: podinfo
@@ -20,11 +30,3 @@ spec:
portName: http
description: "svcmonitor"
kind: ServiceMonitor
- network:
- expose:
- - service: podinfo
- selector:
- app.kubernetes.io/name: podinfo
- gateway: tenant
- host: podinfo
- port: 9898
diff --git a/src/test/tasks.yaml b/src/test/tasks.yaml
index 68ea95e161..e50dc3361d 100644
--- a/src/test/tasks.yaml
+++ b/src/test/tasks.yaml
@@ -15,6 +15,10 @@ tasks:
cmd: |
npx jest test/jest/network.spec.ts
dir: test/jest
+ - description: "Run Test Resources Tests"
+ cmd: |
+ npx jest test/jest/test-resources.spec.ts
+ dir: test/jest
- description: "Remove Test Apps resources"
task: remove
@@ -220,5 +224,5 @@ tasks:
- name: remove
actions:
- - description:
+ - description: Remove the test apps zarf package
cmd: "uds zarf package remove build/zarf-package-uds-core-test-apps-*.zst --confirm --no-progress"
diff --git a/test/jest/test-resources.spec.ts b/test/jest/test-resources.spec.ts
new file mode 100644
index 0000000000..51c7f54c3d
--- /dev/null
+++ b/test/jest/test-resources.spec.ts
@@ -0,0 +1,52 @@
+/**
+ * Copyright 2025 Defense Unicorns
+ * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial
+ */
+
+import { describe, expect, test } from "@jest/globals";
+import { K8s, kind } from "pepr";
+
+jest.setTimeout(30000);
+
+describe("Test Resources Configuration", () => {
+ describe("Istio Ambient Mode", () => {
+ test("Podinfo namespace should have ambient mode label", async () => {
+ // Get the podinfo namespace
+ const namespace = await K8s(kind.Namespace).Get("podinfo");
+
+ // Check for the ambient mode label
+ expect(namespace.metadata?.labels?.["istio.io/dataplane-mode"]).toBe("ambient");
+ });
+
+ test("Podinfo pods should not have istio-proxy sidecar", async () => {
+ // Get all podinfo pods
+ const pods = await K8s(kind.Pod)
+ .InNamespace("podinfo")
+ .WithLabel("app.kubernetes.io/name", "podinfo")
+ .Get();
+
+ // Ensure we have at least one pod to test
+ expect(pods.items.length).toBeGreaterThan(0);
+
+ // Check each pod for absence of istio-proxy in initContainers
+ for (const pod of pods.items) {
+ const initContainers = pod.spec?.initContainers || [];
+ const hasIstioNativeProxy = initContainers.some(container =>
+ container.name === "istio-proxy"
+ );
+
+ expect(hasIstioNativeProxy).toBe(false);
+
+ // Also check regular containers to be thorough
+ const containers = pod.spec?.containers || [];
+ const hasIstioProxy = containers.some(container =>
+ container.name === "istio-proxy"
+ );
+
+ expect(hasIstioProxy).toBe(false);
+ }
+ });
+ });
+
+ // Additional test resource tests can be added in their own describe blocks
+});
|