diff --git a/packages/alchemy/src/Kubernetes/Connection.ts b/packages/alchemy/src/Kubernetes/Connection.ts index 6376e280c2..6bba2a9530 100644 --- a/packages/alchemy/src/Kubernetes/Connection.ts +++ b/packages/alchemy/src/Kubernetes/Connection.ts @@ -2,12 +2,11 @@ * The cluster-agnostic Kubernetes connection model. * * Every `Kubernetes.*` workload (`Deployment`, `Job`, `Manifest`, - * `HelmChart`) targets a cluster through a serializable {@link Connection}: - * the API server endpoint (or enough information to discover it) plus an - * {@link ConnectionAuth} descriptor whose `kind` selects the - * {@link ClusterAdapter} that knows how to authenticate requests — and, - * for managed clouds, how to provision workload identity and container - * images. + * `HelmChart`, and `Secret`) targets a cluster through a serializable + * {@link Connection}: the API server endpoint (or enough information to + * discover it) plus an {@link ConnectionAuth} descriptor whose `kind` selects + * the {@link ClusterAdapter} that knows how to authenticate requests — and, for + * managed clouds, how to provision workload identity and container images. * * A `Connection` is plain data on purpose: it is resolved from resource * attributes at reconcile time and persisted on workload attributes so diff --git a/packages/alchemy/src/Kubernetes/Deployment.ts b/packages/alchemy/src/Kubernetes/Deployment.ts index 9f16c7e32b..f0203f784b 100644 --- a/packages/alchemy/src/Kubernetes/Deployment.ts +++ b/packages/alchemy/src/Kubernetes/Deployment.ts @@ -33,7 +33,7 @@ import { deleteObjects, readObject, reconcileObjects, - KubernetesApiError, + isNotFound, } from "./internal/client.ts"; import { toKubernetesObjectRef, @@ -434,9 +434,6 @@ const retryUntilServiceReady = ( schedule: loadBalancerRetrySchedule, }); -const isNotFound = (error: unknown): error is KubernetesApiError => - error instanceof KubernetesApiError && error.statusCode === 404; - export const DeploymentProvider = () => Provider.effect( Deployment, diff --git a/packages/alchemy/src/Kubernetes/Job.ts b/packages/alchemy/src/Kubernetes/Job.ts index 9f6b055137..4157b6f088 100644 --- a/packages/alchemy/src/Kubernetes/Job.ts +++ b/packages/alchemy/src/Kubernetes/Job.ts @@ -38,7 +38,7 @@ import { deleteObjects, readObject, reconcileObjects, - KubernetesApiError, + isNotFound, } from "./internal/client.ts"; import type { KubernetesObjectDefinition, @@ -394,9 +394,6 @@ export const Job: Platform = }, }); -const isNotFound = (error: unknown): error is KubernetesApiError => - error instanceof KubernetesApiError && error.statusCode === 404; - export const JobProvider = () => Provider.effect( Job, diff --git a/packages/alchemy/src/Kubernetes/Manifest.ts b/packages/alchemy/src/Kubernetes/Manifest.ts index 4b9a41a0a8..459e1da7d3 100644 --- a/packages/alchemy/src/Kubernetes/Manifest.ts +++ b/packages/alchemy/src/Kubernetes/Manifest.ts @@ -12,7 +12,7 @@ import { connectCluster, deleteObject, readObject, - KubernetesApiError, + isNotFound, } from "./internal/client.ts"; import type { KubernetesObjectDefinition, @@ -175,9 +175,6 @@ const toObjectDefinition = ( return Effect.succeed(manifest as KubernetesObjectDefinition); }; -const isNotFound = (error: unknown): error is KubernetesApiError => - error instanceof KubernetesApiError && error.statusCode === 404; - export const ManifestProvider = () => Provider.effect( Manifest, diff --git a/packages/alchemy/src/Kubernetes/Providers.ts b/packages/alchemy/src/Kubernetes/Providers.ts index 8cc34cc893..6c6c87094a 100644 --- a/packages/alchemy/src/Kubernetes/Providers.ts +++ b/packages/alchemy/src/Kubernetes/Providers.ts @@ -5,6 +5,7 @@ import { Deployment, DeploymentProvider } from "./Deployment.ts"; import { HelmChart, HelmChartProvider } from "./HelmChart.ts"; import { Job, JobProvider } from "./Job.ts"; import { Manifest, ManifestProvider } from "./Manifest.ts"; +import { Secret, SecretProvider } from "./Secret.ts"; export class Providers extends Provider.ProviderCollection()( "Kubernetes", @@ -12,7 +13,7 @@ export class Providers extends Provider.ProviderCollection()( /** * The Kubernetes provider layer: the cluster-agnostic workload providers - * (`Deployment`, `Job`, `Manifest`, `HelmChart`) plus the built-in + * (`Deployment`, `Job`, `Manifest`, `HelmChart`, `Secret`) plus the built-in * cluster adapters (`kubeconfig`, `token`, `client-cert`, `exec`). * * Managed-cloud clusters need their platform's adapter alongside — e.g. @@ -29,7 +30,7 @@ export class Providers extends Provider.ProviderCollection()( export const providers = () => Layer.effect( Providers, - Provider.collection([Deployment, HelmChart, Job, Manifest]), + Provider.collection([Deployment, HelmChart, Job, Manifest, Secret]), ).pipe( Layer.provide( Layer.mergeAll( @@ -37,6 +38,7 @@ export const providers = () => HelmChartProvider(), JobProvider(), ManifestProvider(), + SecretProvider(), ), ), // The built-in adapters are provideMerged (not just provided) so the diff --git a/packages/alchemy/src/Kubernetes/Secret.ts b/packages/alchemy/src/Kubernetes/Secret.ts new file mode 100644 index 0000000000..66411882d8 --- /dev/null +++ b/packages/alchemy/src/Kubernetes/Secret.ts @@ -0,0 +1,244 @@ +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { isResolved } from "../Diff.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { + toConnection, + type ClusterLike, + type Connection, +} from "./Connection.ts"; +import { + applyObject, + connectCluster, + deleteObject, + readObject, + isNotFound, +} from "./internal/client.ts"; +import type { KubernetesObjectRef } from "./internal/objects.ts"; +import { encodeSecretData } from "./internal/secret.ts"; +import { + connectionIdentity, + connectionOfOutput, + tryConnectionOf, +} from "./internal/workload.ts"; +import type { Providers } from "./Providers.ts"; + +export interface SecretProps { + /** + * Target cluster the Secret is applied onto. Pass a managed cluster + * resource (e.g. `AWS.EKS.Cluster`), a `Kubernetes.KubeConfig(...)`, or + * a raw `Kubernetes.Connection`. Changing it replaces the Secret. + */ + cluster: ClusterLike; + /** Kubernetes Secret name. Changing it replaces the Secret. */ + name: string; + /** + * Kubernetes namespace. The namespace must already exist. Changing it + * replaces the Secret. + * @default "default" + */ + namespace?: string; + /** + * Kubernetes Secret `type` (e.g. `Opaque`, `kubernetes.io/tls`). Immutable + * on the API server, so changing it replaces the Secret. + * @default "Opaque" + */ + type?: string; + /** Labels applied to the Secret. */ + labels?: Record; + /** Annotations applied to the Secret. */ + annotations?: Record; + /** + * Secret values as UTF-8 strings. They stay `Redacted` in plans, logs, and + * state, and are only unwrapped and base64-encoded while building the + * Kubernetes API request. State still has to hold the real values so later + * updates can re-apply them, so point real credentials at an encrypted + * state backend. + * + * A key may appear in `stringData` or `binaryData`, not both. + */ + stringData?: Record>; + /** + * Secret values that are not UTF-8 text (keystores, PKCS#12 bundles, DER + * certificates), supplied already base64-encoded — the same encoding the + * Kubernetes `data` field uses. Passed through untouched, so the plaintext + * bytes never need to exist in state. Keys must not overlap `stringData`. + */ + binaryData?: Record>; +} + +export interface Secret extends Resource< + "Kubernetes.Secret", + SecretProps, + { + /** The connection of the cluster the Secret is applied to. */ + connection: Connection; + /** Kubernetes Secret name. */ + name: string; + /** Kubernetes namespace. */ + namespace: string; + /** Kubernetes Secret type. */ + type: string; + /** Reference to the applied Secret. */ + ref: KubernetesObjectRef; + /** Server-assigned UID, when returned. */ + uid: string | undefined; + }, + {}, + Providers +> {} + +/** + * A Kubernetes Secret with `Redacted` values. Alchemy never prints the + * plaintext in plans or logs and never writes it to output attributes. The + * only place it is unwrapped is the body of the API request. The values do + * have to live in state so later updates can re-apply them, so use an + * encrypted backend such as `Cloudflare.state()` for real credentials. + * + * ### Creating a Secret + * **Example:** Create an opaque token Secret + * ```typescript + * const connectorToken = yield* Config.redacted("CONNECTOR_TOKEN"); + * const token = yield* Kubernetes.Secret("ConnectorToken", { + * cluster, + * name: "connector-token", + * namespace: "networking", + * stringData: { + * token: connectorToken, + * }, + * }); + * ``` + * + * ### Binary Values + * **Example:** Keystore plus its password + * ```typescript + * const keystore = yield* fs.readFile("./keystore.jks"); + * const bundle = yield* Kubernetes.Secret("Keystore", { + * cluster, + * name: "keystore", + * binaryData: { + * "keystore.jks": Redacted.make(Buffer.from(keystore).toString("base64")), + * }, + * stringData: { + * "keystore-password": yield* Config.redacted("KEYSTORE_PASSWORD"), + * }, + * }); + * ``` + * + * @resource + */ +export const Secret = Resource("Kubernetes.Secret"); + +export { SecretDataKeyConflict } from "./internal/secret.ts"; + +export const SecretProvider = () => + Provider.effect( + Secret, + Effect.gen(function* () { + return { + stables: ["connection", "name", "namespace", "type"], + // In-cluster objects have no cloud-side enumeration that attributes + // them to alchemy; refresh happens per-instance through `read`. + list: () => Effect.succeed([] as Secret["Attributes"][]), + diff: Effect.fn(function* ({ olds = {} as SecretProps, news }) { + if (!isResolved(news)) return; + // Surface a stringData/binaryData key collision at plan time + // rather than mid-apply. + yield* encodeSecretData(news); + const oldCluster = connectionIdentity(tryConnectionOf(olds.cluster)); + const newCluster = connectionIdentity(tryConnectionOf(news.cluster)); + // Object identity (cluster, name, namespace) and `type` are + // immutable — changing any of them is a replacement. + if ( + olds.name !== undefined && + ((oldCluster !== undefined && + newCluster !== undefined && + oldCluster !== newCluster) || + olds.name !== news.name || + (olds.namespace ?? "default") !== (news.namespace ?? "default") || + (olds.type ?? "Opaque") !== (news.type ?? "Opaque")) + ) { + return { action: "replace" } as const; + } + }), + read: Effect.fn(function* ({ output }) { + if (!output) return undefined; + const connection = connectionOfOutput(output); + if (!connection) return undefined; + const transport = yield* connectCluster(connection).pipe( + // Cluster gone — its objects went with it. + Effect.catchTag("Kubernetes.ClusterNotFoundError", () => + Effect.succeed(undefined), + ), + ); + if (!transport) return undefined; + const observed = yield* readObject({ + transport, + object: output.ref, + }).pipe(Effect.catchIf(isNotFound, () => Effect.succeed(undefined))); + if (!observed) return undefined; + const object = observed as { + metadata?: { uid?: string }; + type?: string; + }; + return { + ...output, + type: object.type ?? output.type, + uid: object.metadata?.uid ?? output.uid, + }; + }), + reconcile: Effect.fn(function* ({ news, output, session }) { + const connection = toConnection(news.cluster); + const transport = yield* connectCluster(connection); + const namespace = news.namespace ?? "default"; + const type = news.type ?? "Opaque"; + const ref: KubernetesObjectRef = { + apiVersion: "v1", + kind: "Secret", + name: news.name, + namespace, + }; + // Server-side apply is a true upsert: create-if-missing and + // converge-if-present in one call, `force: true` so alchemy owns + // the fields it manages regardless of prior managers. + const applied = yield* applyObject({ + transport, + object: { + apiVersion: "v1", + kind: "Secret", + metadata: { + name: news.name, + namespace, + labels: news.labels, + annotations: news.annotations, + }, + type, + data: yield* encodeSecretData(news), + }, + }); + yield* session.note(`Applied v1/Secret ${namespace}/${news.name}`); + const uid = + (applied as { metadata?: { uid?: string } })?.metadata?.uid ?? + output?.uid; + return { connection, name: news.name, namespace, type, ref, uid }; + }), + delete: Effect.fn(function* ({ output }) { + const connection = connectionOfOutput(output); + if (!connection) return; + const transport = yield* connectCluster(connection).pipe( + // Cluster already destroyed — nothing left to delete. + Effect.catchTag("Kubernetes.ClusterNotFoundError", () => + Effect.succeed(undefined), + ), + ); + if (!transport) return; + yield* deleteObject({ transport, object: output.ref }).pipe( + // Tolerate any residual API failure so delete stays idempotent + // (e.g. the namespace is already terminating). + Effect.catch(() => Effect.void), + ); + }), + }; + }), + ); diff --git a/packages/alchemy/src/Kubernetes/index.ts b/packages/alchemy/src/Kubernetes/index.ts index fae966f4b4..9f16fe974d 100644 --- a/packages/alchemy/src/Kubernetes/index.ts +++ b/packages/alchemy/src/Kubernetes/index.ts @@ -6,3 +6,4 @@ export * from "./HelmChart.ts"; export * from "./Job.ts"; export * from "./Manifest.ts"; export * from "./Providers.ts"; +export * from "./Secret.ts"; diff --git a/packages/alchemy/src/Kubernetes/internal/client.ts b/packages/alchemy/src/Kubernetes/internal/client.ts index 359612a7ea..7d3abfb89d 100644 --- a/packages/alchemy/src/Kubernetes/internal/client.ts +++ b/packages/alchemy/src/Kubernetes/internal/client.ts @@ -2,8 +2,8 @@ * Internal Kubernetes API client: transport-agnostic server-side apply and * kind discovery for arbitrary (CRD) manifests. Powers * `Kubernetes.Manifest`, `Kubernetes.Deployment`, `Kubernetes.Job`, - * `Kubernetes.HelmChart`, and the `AWS.EKS.Cluster` kubernetes-object - * binding channel. Not exported from the Kubernetes index. + * `Kubernetes.HelmChart`, `Kubernetes.Secret`, and the `AWS.EKS.Cluster` + * kubernetes-object binding channel. Not exported from the Kubernetes index. * * Authentication is delegated to the connection's {@link ClusterAdapter}: * every request mints headers through the resolved @@ -45,6 +45,13 @@ export class KubernetesApiError extends Data.TaggedError("KubernetesApiError")<{ } } +/** + * True when the API server answered 404: the object, or its whole kind, does + * not exist. + */ +export const isNotFound = (error: unknown): error is KubernetesApiError => + error instanceof KubernetesApiError && error.statusCode === 404; + const fieldManager = "alchemy"; /** diff --git a/packages/alchemy/src/Kubernetes/internal/secret.ts b/packages/alchemy/src/Kubernetes/internal/secret.ts new file mode 100644 index 0000000000..419062e1ec --- /dev/null +++ b/packages/alchemy/src/Kubernetes/internal/secret.ts @@ -0,0 +1,55 @@ +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; + +/** + * A key was supplied in both `stringData` and `binaryData`. Kubernetes + * resolves this silently (`stringData` wins), which hides mistakes; alchemy + * refuses instead. + */ +export class SecretDataKeyConflict extends Data.TaggedError( + "Kubernetes.SecretDataKeyConflict", +)<{ + keys: string[]; +}> { + override get message(): string { + return `Kubernetes.Secret keys must be unique across stringData and binaryData; duplicated: ${this.keys.join(", ")}`; + } +} + +export interface SecretData { + stringData?: Record>; + binaryData?: Record>; +} + +/** + * Merge `stringData` (UTF-8, base64-encoded here) and `binaryData` (already + * base64) into the wire-level `data` map. Values are unwrapped only at this + * edge, immediately before the Kubernetes API request. + */ +export const encodeSecretData = ({ + stringData = {}, + binaryData = {}, +}: SecretData): Effect.Effect, SecretDataKeyConflict> => + Effect.gen(function* () { + const conflicts = Object.keys(stringData).filter((key) => + Object.hasOwn(binaryData, key), + ); + if (conflicts.length > 0) { + return yield* new SecretDataKeyConflict({ keys: conflicts }); + } + return yield* Effect.sync(() => ({ + ...Object.fromEntries( + Object.entries(stringData).map(([key, value]) => [ + key, + Buffer.from(Redacted.value(value), "utf8").toString("base64"), + ]), + ), + ...Object.fromEntries( + Object.entries(binaryData).map(([key, value]) => [ + key, + Redacted.value(value), + ]), + ), + })); + }); diff --git a/packages/alchemy/test/Kubernetes/Deployment.test.ts b/packages/alchemy/test/Kubernetes/Deployment.test.ts index 0479074c3a..b7eb147bd9 100644 --- a/packages/alchemy/test/Kubernetes/Deployment.test.ts +++ b/packages/alchemy/test/Kubernetes/Deployment.test.ts @@ -11,6 +11,7 @@ import * as dynamodb from "@distilled.cloud/aws/dynamodb"; import { describe, expect } from "alchemy-test"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; import * as Schedule from "effect/Schedule"; import * as HttpClient from "effect/unstable/http/HttpClient"; import EksHostApi from "./fixtures/deployment.ts"; @@ -85,6 +86,19 @@ describe.skipIf(!process.env.AWS_TEST_SLOW)("Kubernetes Deployment E2E", () => { let baseUrl: string; let helmRelease: Kubernetes.HelmChart["Attributes"]; let helmCluster: Cluster["Attributes"]; + let e2eSecret: Kubernetes.Secret["Attributes"]; + const clusterTransport = () => + makeEksTransport({ + clusterName: helmCluster.clusterName, + endpoint: helmCluster.endpoint!, + certificateAuthorityData: helmCluster.certificateAuthorityData!, + }); + const E2E_SECRET_VALUE = "alchemy-e2e-value"; + const E2E_SECRET_VALUE_B64 = Buffer.from(E2E_SECRET_VALUE).toString("base64"); + // Non-UTF-8 bytes, supplied base64-encoded the way the API's `data` is. + const E2E_SECRET_BINARY_B64 = Buffer.from([0x00, 0xff, 0x10]).toString( + "base64", + ); beforeAll( Effect.gen(function* () { @@ -92,8 +106,9 @@ describe.skipIf(!process.env.AWS_TEST_SLOW)("Kubernetes Deployment E2E", () => { // Phase 1: cluster + network only. yield* sharedStack.deploy(infra); // Phase 2: same infra + the Deployment fixture (refs the cluster) + - // a HelmChart rendering the local fixture chart onto the cluster. - const { host, cluster, release } = yield* sharedStack.deploy( + // a HelmChart rendering the local fixture chart onto the cluster + + // a Secret with Redacted string data. + const { host, cluster, release, secret } = yield* sharedStack.deploy( Effect.gen(function* () { const { cluster } = yield* infra; const release = yield* Kubernetes.HelmChart("E2EHelmChart", { @@ -101,12 +116,19 @@ describe.skipIf(!process.env.AWS_TEST_SLOW)("Kubernetes Deployment E2E", () => { chart: `${import.meta.dirname}/fixtures/chart`, values: { message: "helm-e2e", secondConfigMap: { enabled: true } }, }); + const secret = yield* Kubernetes.Secret("E2ESecret", { + cluster, + name: "alchemy-e2e-secret", + stringData: { token: Redacted.make(E2E_SECRET_VALUE) }, + binaryData: { blob: Redacted.make(E2E_SECRET_BINARY_B64) }, + }); const host = yield* EksHostApi; - return { host, cluster, release }; + return { host, cluster, release, secret }; }), ); helmRelease = release; helmCluster = cluster; + e2eSecret = secret; // `url` is a full URL (`http://:` — the NLB // listener is the Service port, not 80). expect(host.url).toBeTruthy(); @@ -178,11 +200,7 @@ describe.skipIf(!process.env.AWS_TEST_SLOW)("Kubernetes Deployment E2E", () => { object.name.endsWith("-config"), )!; expect(configRef).toBeDefined(); - const transport = yield* makeEksTransport({ - clusterName: helmCluster.clusterName, - endpoint: helmCluster.endpoint!, - certificateAuthorityData: helmCluster.certificateAuthorityData!, - }); + const transport = yield* clusterTransport(); const applied = (yield* readObject({ transport, object: configRef, @@ -192,4 +210,23 @@ describe.skipIf(!process.env.AWS_TEST_SLOW)("Kubernetes Deployment E2E", () => { }), { timeout: 120_000 }, ); + + test.provider( + "Secret applies Redacted string data without exposing it in attributes", + () => + Effect.gen(function* () { + // Attributes must not carry the plaintext or its base64 form. + const serialized = JSON.stringify(e2eSecret); + expect(serialized).not.toContain(E2E_SECRET_VALUE); + expect(serialized).not.toContain(E2E_SECRET_VALUE_B64); + const transport = yield* clusterTransport(); + const applied = (yield* readObject({ + transport, + object: e2eSecret.ref, + })) as { data?: Record } | undefined; + expect(applied?.data?.token).toBe(E2E_SECRET_VALUE_B64); + expect(applied?.data?.blob).toBe(E2E_SECRET_BINARY_B64); + }), + { timeout: 120_000 }, + ); }); diff --git a/packages/alchemy/test/Kubernetes/Secret.test.ts b/packages/alchemy/test/Kubernetes/Secret.test.ts new file mode 100644 index 0000000000..23ac1bd4e1 --- /dev/null +++ b/packages/alchemy/test/Kubernetes/Secret.test.ts @@ -0,0 +1,63 @@ +import * as Kubernetes from "@/Kubernetes"; +import { encodeSecretData } from "@/Kubernetes/internal/secret.ts"; +import * as Provider from "@/Provider"; +import * as Test from "@/Test/Alchemy"; +import { expect, it } from "alchemy-test"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; + +const { test } = Test.make({ providers: Kubernetes.providers() }); + +it.effect("encodes Redacted values for the Kubernetes API", () => + Effect.gen(function* () { + const value = Redacted.make("secret-value"); + const encoded = yield* encodeSecretData({ stringData: { token: value } }); + expect(encoded).toEqual({ + token: Buffer.from("secret-value", "utf8").toString("base64"), + }); + expect(JSON.stringify(value)).toBe('""'); + }), +); + +it.effect("passes binaryData through as base64 alongside stringData", () => + Effect.gen(function* () { + const bytes = Buffer.from([0x00, 0xff, 0x10]).toString("base64"); + const encoded = yield* encodeSecretData({ + stringData: { password: Redacted.make("hunter2") }, + binaryData: { "keystore.jks": Redacted.make(bytes) }, + }); + expect(encoded).toEqual({ + password: Buffer.from("hunter2", "utf8").toString("base64"), + "keystore.jks": bytes, + }); + }), +); + +it.effect("rejects a key present in both stringData and binaryData", () => + Effect.gen(function* () { + const result = yield* Effect.result( + encodeSecretData({ + stringData: { shared: Redacted.make("a"), only: Redacted.make("b") }, + binaryData: { shared: Redacted.make("Yg==") }, + }), + ); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure._tag).toBe("Kubernetes.SecretDataKeyConflict"); + expect(result.failure.keys).toEqual(["shared"]); + } + }), +); + +// Ungated probe. Like `Manifest`, a `Secret` lives inside the cluster and +// nothing on the cloud side attributes it to alchemy, so `list()` is +// intentionally empty. The full lifecycle is covered in Deployment.test.ts, +// which reuses that suite's gated EKS cluster instead of paying for another. +test.provider("list returns an empty array (in-cluster objects)", () => + Effect.gen(function* () { + const provider = yield* Provider.findProvider(Kubernetes.Secret); + const all = yield* provider.list(); + expect(all).toEqual([]); + }), +); diff --git a/website/src/content/docs/aws/compute/eks.mdx b/website/src/content/docs/aws/compute/eks.mdx index a792763d13..60c4bd12bd 100644 --- a/website/src/content/docs/aws/compute/eks.mdx +++ b/website/src/content/docs/aws/compute/eks.mdx @@ -219,8 +219,9 @@ const nightly = yield* Kubernetes.Job("NightlyBackfill", { [`Kubernetes.Manifest`](/providers/kubernetes/manifest) applies any raw Kubernetes object — StatefulSets, Namespaces, CRDs — via server-side apply. -The manifest is a literal object, exactly as you would write it in -YAML: +For Secrets, prefer [`Kubernetes.Secret`](/providers/kubernetes/secret), which +keeps values `Redacted` in plans and state. The manifest is a literal object, +exactly as you would write it in YAML: ```typescript const namespace = yield* Kubernetes.Manifest("DemoNamespace", {