@alchemy.run/docker 2.0.0-beta.84 → 2.0.0-beta.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +16 -0
  2. package/lib/Bootstrap.d.ts.map +1 -1
  3. package/lib/Bootstrap.js +2 -1
  4. package/lib/Bootstrap.js.map +1 -1
  5. package/lib/BuildCache.d.ts +87 -0
  6. package/lib/BuildCache.d.ts.map +1 -0
  7. package/lib/BuildCache.js +39 -0
  8. package/lib/BuildCache.js.map +1 -0
  9. package/lib/BuildHash.d.ts +20 -3
  10. package/lib/BuildHash.d.ts.map +1 -1
  11. package/lib/BuildHash.js +60 -21
  12. package/lib/BuildHash.js.map +1 -1
  13. package/lib/BuildPlan.d.ts +202 -0
  14. package/lib/BuildPlan.d.ts.map +1 -0
  15. package/lib/BuildPlan.js +514 -0
  16. package/lib/BuildPlan.js.map +1 -0
  17. package/lib/Container.d.ts +122 -1
  18. package/lib/Container.d.ts.map +1 -1
  19. package/lib/Container.js +232 -4
  20. package/lib/Container.js.map +1 -1
  21. package/lib/Context.d.ts +20 -1
  22. package/lib/Context.d.ts.map +1 -1
  23. package/lib/Context.js +15 -0
  24. package/lib/Context.js.map +1 -1
  25. package/lib/Docker.d.ts +113 -1
  26. package/lib/Docker.d.ts.map +1 -1
  27. package/lib/Docker.js +50 -8
  28. package/lib/Docker.js.map +1 -1
  29. package/lib/Dockerfile.d.ts +0 -2
  30. package/lib/Dockerfile.d.ts.map +1 -1
  31. package/lib/Dockerfile.js.map +1 -1
  32. package/lib/DockerfileParser.d.ts +125 -0
  33. package/lib/DockerfileParser.d.ts.map +1 -0
  34. package/lib/DockerfileParser.js +1040 -0
  35. package/lib/DockerfileParser.js.map +1 -0
  36. package/lib/HealthcheckCommand.d.ts.map +1 -1
  37. package/lib/HealthcheckCommand.js.map +1 -1
  38. package/lib/Image.d.ts +69 -13
  39. package/lib/Image.d.ts.map +1 -1
  40. package/lib/Image.js +59 -9
  41. package/lib/Image.js.map +1 -1
  42. package/lib/ImageArchive.d.ts +41 -0
  43. package/lib/ImageArchive.d.ts.map +1 -0
  44. package/lib/ImageArchive.js +54 -0
  45. package/lib/ImageArchive.js.map +1 -0
  46. package/lib/ImageSource.d.ts +151 -8
  47. package/lib/ImageSource.d.ts.map +1 -1
  48. package/lib/ImageSource.js +247 -72
  49. package/lib/ImageSource.js.map +1 -1
  50. package/lib/Network.d.ts +175 -3
  51. package/lib/Network.d.ts.map +1 -1
  52. package/lib/Network.js +296 -8
  53. package/lib/Network.js.map +1 -1
  54. package/lib/Providers.d.ts.map +1 -1
  55. package/lib/Providers.js.map +1 -1
  56. package/lib/Registry.d.ts.map +1 -1
  57. package/lib/Registry.js.map +1 -1
  58. package/lib/RegistryError.d.ts.map +1 -1
  59. package/lib/RegistryError.js.map +1 -1
  60. package/lib/RemoteImage.d.ts.map +1 -1
  61. package/lib/RemoteImage.js +1 -1
  62. package/lib/RemoteImage.js.map +1 -1
  63. package/lib/Service.d.ts +24 -3
  64. package/lib/Service.d.ts.map +1 -1
  65. package/lib/Service.js +154 -12
  66. package/lib/Service.js.map +1 -1
  67. package/lib/ServiceImage.d.ts.map +1 -1
  68. package/lib/ServiceImage.js.map +1 -1
  69. package/lib/ShellWord.d.ts +28 -0
  70. package/lib/ShellWord.d.ts.map +1 -0
  71. package/lib/ShellWord.js +301 -0
  72. package/lib/ShellWord.js.map +1 -0
  73. package/lib/Swarm.d.ts.map +1 -1
  74. package/lib/Swarm.js.map +1 -1
  75. package/lib/Volume.d.ts +24 -1
  76. package/lib/Volume.d.ts.map +1 -1
  77. package/lib/Volume.js +16 -0
  78. package/lib/Volume.js.map +1 -1
  79. package/lib/index.d.ts.map +1 -1
  80. package/lib/index.js.map +1 -1
  81. package/package.json +9 -9
  82. package/src/Bootstrap.ts +2 -0
  83. package/src/BuildCache.ts +111 -0
  84. package/src/BuildHash.ts +85 -26
  85. package/src/BuildPlan.ts +758 -0
  86. package/src/Container.ts +361 -5
  87. package/src/Context.ts +27 -1
  88. package/src/Docker.ts +157 -9
  89. package/src/Dockerfile.ts +0 -2
  90. package/src/DockerfileParser.ts +1152 -0
  91. package/src/Image.ts +75 -15
  92. package/src/ImageArchive.ts +77 -0
  93. package/src/ImageSource.ts +391 -99
  94. package/src/Network.ts +432 -10
  95. package/src/RemoteImage.ts +1 -1
  96. package/src/Service.ts +246 -62
  97. package/src/ShellWord.ts +326 -0
  98. package/src/Volume.ts +33 -1
package/src/Container.ts CHANGED
@@ -66,6 +66,57 @@ export interface ContainerProps {
66
66
  networkMode?: Container.NetworkMode;
67
67
  /** Linux capabilities to add, for example `SYS_ADMIN`. */
68
68
  capAdd?: string[];
69
+ /** Linux capabilities to drop, for example `["ALL"]`. */
70
+ capDrop?: string[];
71
+ /** Hard memory limit in Docker's byte-suffix format, e.g. `"512m"`. */
72
+ memory?: string;
73
+ /**
74
+ * Combined memory-plus-swap limit in Docker's byte-suffix format.
75
+ * Requires `memory`. Set to the same value to disable swap, or `"-1"`
76
+ * to allow unlimited swap (subject to the host).
77
+ */
78
+ memorySwap?: string;
79
+ /** Soft memory limit under contention, e.g. `"256m"`. */
80
+ memoryReservation?: string;
81
+ /** CPU quota, in cores (e.g. `0.5` limits to half a CPU). */
82
+ cpus?: number;
83
+ /** Maximum number of processes and threads in the container. */
84
+ pidsLimit?: number;
85
+ /** Mount the container's root filesystem read-only. Writable paths need a volume or tmpfs. */
86
+ readOnlyRootFilesystem?: boolean;
87
+ /** Prevent processes from gaining additional privileges through exec. */
88
+ noNewPrivileges?: boolean;
89
+ /** Run Docker's init process to forward signals and reap child processes. */
90
+ init?: boolean;
91
+ /** Runtime user, e.g. `"1000:1000"` or `"app"`. Omit to keep the image's user. */
92
+ user?: string;
93
+ /**
94
+ * Seccomp JSON profile path on the Docker CLI host, or `"builtin"` to
95
+ * select Docker's default policy. `"unconfined"` explicitly disables it.
96
+ * Changes to a profile file's contents recreate the container.
97
+ */
98
+ seccompProfile?: string;
99
+ /**
100
+ * Name of a preloaded AppArmor profile, e.g. `"docker-default"`.
101
+ * Requires AppArmor support on the target host. `"unconfined"` disables it.
102
+ */
103
+ appArmorProfile?: string;
104
+ /** Docker user-namespace mode. `"host"` opts out of daemon user remapping. */
105
+ userNamespace?: string;
106
+ /** Supplementary Linux group names or numeric GIDs. */
107
+ groupAdd?: string[];
108
+ /** Per-process resource limits. `-1` represents unlimited. */
109
+ ulimits?: Record<string, Container.Ulimit>;
110
+ /** Docker log driver, e.g. `"local"`, `"json-file"`, or `"none"`. */
111
+ logDriver?: string;
112
+ /** Driver-specific settings, such as `{ "max-size": "10m", "max-file": "3" }`. */
113
+ logOptions?: Record<string, string>;
114
+ /** Relative OOM score adjustment on Linux, from -1000 to 1000. */
115
+ oomScoreAdjustment?: number;
116
+ /** Stop signal, e.g. `"SIGTERM"` or `"SIGINT"`. */
117
+ stopSignal?: string;
118
+ /** Writable in-memory mounts, including size and POSIX mode controls. */
119
+ tmpfs?: Container.TmpfsMount[];
69
120
  /** Host devices to expose to the container. */
70
121
  devices?: Container.DeviceMapping[];
71
122
  /**
@@ -107,6 +158,20 @@ export declare namespace Container {
107
158
  /** Mount read-only. @default false */
108
159
  readOnly?: boolean;
109
160
  }
161
+ interface TmpfsMount {
162
+ /** Absolute mount target in the container, e.g. `"/tmp"`. */
163
+ path: string;
164
+ /** Docker byte-suffix size, e.g. `"32m"`. Omit for the engine default. */
165
+ size?: string;
166
+ /** POSIX mode as a number, e.g. `0o1777` for a sticky world-writable directory. */
167
+ mode?: number;
168
+ }
169
+ interface Ulimit {
170
+ /** Process soft limit. `-1` means unlimited. */
171
+ soft: number;
172
+ /** Process hard limit. `-1` means unlimited. */
173
+ hard: number;
174
+ }
110
175
  interface NetworkMapping {
111
176
  /** Network name or ID. */
112
177
  name: string;
@@ -170,9 +235,24 @@ export interface Container extends Resource<
170
235
  devices?: Container.DeviceMapping[];
171
236
  },
172
237
  never,
173
- Providers
238
+ Providers,
239
+ ContainerLookup
174
240
  > {}
175
241
 
242
+ /**
243
+ * Props for {@link Container.lookup}: find a container this stack
244
+ * doesn't manage by its name.
245
+ */
246
+ export interface ContainerLookup {
247
+ /** Container name. */
248
+ name: string;
249
+ /**
250
+ * Docker context the engine is reached through.
251
+ * @default the current context
252
+ */
253
+ context?: string;
254
+ }
255
+
176
256
  /**
177
257
  * A Docker container managed through the active Docker context.
178
258
  *
@@ -280,6 +360,39 @@ export interface Container extends Resource<
280
360
  * ```
281
361
  *
282
362
  * ### Runtime Options
363
+ * **Example:** Contain a background worker
364
+ * ```typescript
365
+ * const worker = yield* Docker.Container("worker", {
366
+ * image: "oven/bun:1",
367
+ * memory: "512m",
368
+ * memorySwap: "512m",
369
+ * memoryReservation: "256m",
370
+ * cpus: 0.5,
371
+ * pidsLimit: 128,
372
+ * readOnlyRootFilesystem: true,
373
+ * capDrop: ["ALL"],
374
+ * noNewPrivileges: true,
375
+ * init: true,
376
+ * user: "1000:1000",
377
+ * tmpfs: [{ path: "/tmp", size: "32m", mode: 0o1777 }],
378
+ * });
379
+ * ```
380
+ *
381
+ * **Example:** Customize confinement, process limits, and log rotation
382
+ * ```typescript
383
+ * const api = yield* Docker.Container("api", {
384
+ * image: "ghcr.io/acme/api:latest",
385
+ * seccompProfile: "./security/api-seccomp.json", // client-side JSON; edits roll the container
386
+ * appArmorProfile: "my-loaded-profile", // must already be loaded on the host
387
+ * groupAdd: ["1000"],
388
+ * ulimits: { nofile: { soft: 1024, hard: 4096 } },
389
+ * logDriver: "local",
390
+ * logOptions: { "max-size": "10m", "max-file": "3" },
391
+ * oomScoreAdjustment: 200,
392
+ * stopSignal: "SIGTERM",
393
+ * });
394
+ * ```
395
+ *
283
396
  * **Example:** Share a donor container's network namespace
284
397
  * ```typescript
285
398
  * const donor = yield* Docker.Container("donor", { image: "redis:alpine" });
@@ -338,6 +451,16 @@ export interface Container extends Resource<
338
451
  * });
339
452
  * ```
340
453
  *
454
+ * ### Looking up an existing container
455
+ * `Container.lookup` references a container Alchemy doesn't manage (created with
456
+ * the Docker CLI, Compose or another stack). It is read during plan/deploy,
457
+ * never created, updated or removed.
458
+ *
459
+ * **Example:** Reference an existing container
460
+ * ```typescript
461
+ * const db = Docker.Container.lookup({ name: "postgres" });
462
+ * ```
463
+ *
341
464
  * @resource
342
465
  * @product Container
343
466
  */
@@ -381,6 +504,14 @@ export const ContainerProvider = () =>
381
504
  return yield* sha256(digests.join("\n"));
382
505
  });
383
506
 
507
+ // Docker CLI loads seccomp profiles from client-side files at create
508
+ // time. Hash the contents as well as the path so policy edits are never
509
+ // silently ignored on subsequent deploys.
510
+ const seccompProfileDigest = Effect.fn(function* (profile: string | undefined) {
511
+ if (!isCustomSeccompProfile(profile)) return undefined;
512
+ return yield* fs.readFile(path.resolve(profile)).pipe(Effect.flatMap(sha256));
513
+ });
514
+
384
515
  // Without env files the hash input is unchanged from before they were
385
516
  // supported, so existing containers keep their label and are not rolled.
386
517
  const desiredConfigHash = Effect.fn(function* (
@@ -388,10 +519,12 @@ export const ContainerProvider = () =>
388
519
  news: ContainerProps,
389
520
  ) {
390
521
  const envFiles = yield* envFilesDigest(news.envFiles);
522
+ const seccompProfile = yield* seccompProfileDigest(news.seccompProfile);
391
523
  return yield* sha256Object({
392
524
  ...args,
393
525
  imageId: normalizeImageId(news.image),
394
526
  ...(envFiles === undefined ? {} : { envFiles }),
527
+ ...(seccompProfile === undefined ? {} : { seccompProfile }),
395
528
  });
396
529
  });
397
530
 
@@ -454,6 +587,11 @@ export const ContainerProvider = () =>
454
587
 
455
588
  return Container.Provider.of({
456
589
  list: () => Effect.succeed([]),
590
+ lookup: Effect.fn(function* ({ name, context }) {
591
+ const info = yield* inspect(name, context);
592
+ return info ? toContainerAttributes(info, info.Config.Image) : undefined;
593
+ }),
594
+
457
595
  read: Effect.fn(function* ({ id, instanceId, olds, output }) {
458
596
  const context = dockerContextName(olds.context);
459
597
  const name = yield* dockerPhysicalName(id, olds, instanceId);
@@ -492,9 +630,9 @@ export const ContainerProvider = () =>
492
630
  ) {
493
631
  return { action: "update" as const };
494
632
  }
495
- // Same env file paths, possibly new contents: compare against the
496
- // hash stamped on the running container.
497
- if (news.envFiles?.length) {
633
+ // File-backed inputs can change without changing their paths.
634
+ // Compare their digests against the live config hash.
635
+ if (news.envFiles?.length || isCustomSeccompProfile(news.seccompProfile)) {
498
636
  const live = yield* inspect(newArgs.name, dockerContextName(news.context));
499
637
  const applied = live?.Config.Labels?.[CREATE_CONFIG_HASH_LABEL];
500
638
  if (applied !== undefined && applied !== (yield* desiredConfigHash(newArgs, news))) {
@@ -538,7 +676,12 @@ export const ContainerProvider = () =>
538
676
  !matchesLegacyConfig(live, args, news.image) ||
539
677
  // Env file contents can't be compared without a label (e.g. an
540
678
  // adopted container); recreate once so later edits are tracked.
541
- (news.envFiles?.length ?? 0) > 0
679
+ (news.envFiles?.length ?? 0) > 0 ||
680
+ isCustomSeccompProfile(news.seccompProfile) ||
681
+ // A legacy/adopted container has no stamped config hash.
682
+ // Do not retain it without proof that the requested security
683
+ // restrictions were applied.
684
+ hasHardeningOptions(news)
542
685
  : live.Config.Labels[CREATE_CONFIG_HASH_LABEL] !== configHash);
543
686
  if (recreate) {
544
687
  yield* remove(live.Id, context);
@@ -634,6 +777,30 @@ const makeCreateArgs = (id: string, news: ContainerProps, instanceId: string) =>
634
777
  "add-host": news.extraHosts,
635
778
  network: normalizeNetworkMode(news.networkMode),
636
779
  "cap-add": normalizeCapabilities(news.capAdd),
780
+ ...(news.capDrop?.length ? { "cap-drop": normalizeCapabilities(news.capDrop) } : {}),
781
+ ...(news.memory !== undefined ? { memory: news.memory } : {}),
782
+ ...(news.memorySwap !== undefined ? { "memory-swap": news.memorySwap } : {}),
783
+ ...(news.memoryReservation !== undefined
784
+ ? { "memory-reservation": news.memoryReservation }
785
+ : {}),
786
+ ...(news.cpus !== undefined ? { cpus: news.cpus } : {}),
787
+ ...(news.pidsLimit !== undefined ? { "pids-limit": news.pidsLimit } : {}),
788
+ ...(news.readOnlyRootFilesystem ? { "read-only": true } : {}),
789
+ ...(normalizeSecurityOptions(news) ? { "security-opt": normalizeSecurityOptions(news) } : {}),
790
+ ...(news.userNamespace !== undefined ? { userns: news.userNamespace } : {}),
791
+ ...(news.groupAdd?.length ? { "group-add": normalizeGroups(news.groupAdd) } : {}),
792
+ ...(Object.keys(news.ulimits ?? {}).length ? { ulimit: normalizeUlimits(news.ulimits) } : {}),
793
+ ...(news.logDriver !== undefined ? { "log-driver": news.logDriver } : {}),
794
+ ...(Object.keys(news.logOptions ?? {}).length
795
+ ? { "log-opt": normalizeLogOptions(news.logOptions) }
796
+ : {}),
797
+ ...(news.oomScoreAdjustment !== undefined
798
+ ? { "oom-score-adj": news.oomScoreAdjustment }
799
+ : {}),
800
+ ...(news.stopSignal !== undefined ? { "stop-signal": news.stopSignal } : {}),
801
+ ...(news.init ? { init: true } : {}),
802
+ ...(news.user !== undefined ? { user: news.user } : {}),
803
+ ...(news.tmpfs?.length ? { tmpfs: normalizeTmpfs(news.tmpfs) } : {}),
637
804
  device: normalizeDevices(news.devices),
638
805
  restart: news.restart ?? "no",
639
806
  label: news.labels,
@@ -689,6 +856,64 @@ const normalizeCapabilities = (capAdd: string[] | undefined): string[] | undefin
689
856
  return [...new Set(capAdd.map((capability) => capability.trim()).filter(Boolean))].sort();
690
857
  };
691
858
 
859
+ const isCustomSeccompProfile = (profile: string | undefined): profile is string =>
860
+ profile !== undefined && profile !== "builtin" && profile !== "unconfined";
861
+
862
+ const normalizeSecurityOptions = (props: ContainerProps): string[] | undefined => {
863
+ const options = [
864
+ ...(props.noNewPrivileges ? ["no-new-privileges"] : []),
865
+ ...(props.seccompProfile ? [`seccomp=${props.seccompProfile}`] : []),
866
+ ...(props.appArmorProfile ? [`apparmor=${props.appArmorProfile}`] : []),
867
+ ];
868
+ return options.length ? options : undefined;
869
+ };
870
+
871
+ const normalizeGroups = (groups: string[]): string[] =>
872
+ [...new Set(groups.map((group) => group.trim()))].sort();
873
+
874
+ const normalizeUlimits = (limits: ContainerProps["ulimits"]): string[] =>
875
+ Object.entries(limits ?? {})
876
+ .sort(([left], [right]) => left.localeCompare(right))
877
+ .map(([name, { soft, hard }]) => `${name}=${soft}:${hard}`);
878
+
879
+ const normalizeLogOptions = (options: ContainerProps["logOptions"]): string[] =>
880
+ Object.entries(options ?? {})
881
+ .sort(([left], [right]) => left.localeCompare(right))
882
+ .map(([name, value]) => `${name}=${value}`);
883
+
884
+ const normalizeTmpfs = (mounts: Container.TmpfsMount[]): string[] =>
885
+ [...mounts]
886
+ .sort((a, b) => a.path.localeCompare(b.path))
887
+ .map(({ path, size, mode }) => {
888
+ const options = [
889
+ ...(size !== undefined ? [`size=${size}`] : []),
890
+ ...(mode !== undefined ? [`mode=${mode.toString(8)}`] : []),
891
+ ];
892
+ return options.length > 0 ? `${path}:${options.join(",")}` : path;
893
+ });
894
+
895
+ const hasHardeningOptions = (props: ContainerProps): boolean =>
896
+ (props.capDrop?.length ?? 0) > 0 ||
897
+ props.memory !== undefined ||
898
+ props.memorySwap !== undefined ||
899
+ props.memoryReservation !== undefined ||
900
+ props.cpus !== undefined ||
901
+ props.pidsLimit !== undefined ||
902
+ props.readOnlyRootFilesystem === true ||
903
+ props.noNewPrivileges === true ||
904
+ props.seccompProfile !== undefined ||
905
+ props.appArmorProfile !== undefined ||
906
+ props.userNamespace !== undefined ||
907
+ (props.groupAdd?.length ?? 0) > 0 ||
908
+ Object.keys(props.ulimits ?? {}).length > 0 ||
909
+ props.logDriver !== undefined ||
910
+ Object.keys(props.logOptions ?? {}).length > 0 ||
911
+ props.oomScoreAdjustment !== undefined ||
912
+ props.stopSignal !== undefined ||
913
+ props.init === true ||
914
+ props.user !== undefined ||
915
+ (props.tmpfs?.length ?? 0) > 0;
916
+
692
917
  const normalizeDevices = (devices: Container.DeviceMapping[] | undefined): string[] | undefined => {
693
918
  if (!devices?.length) return undefined;
694
919
  const normalized = devices.map(
@@ -707,6 +932,102 @@ export class InvalidContainerOptions extends Data.TaggedError("InvalidContainerO
707
932
  }> {}
708
933
 
709
934
  const validateContainerOptions = (news: ContainerProps) => {
935
+ // The CLI formatter omits empty strings. Reject them instead of silently
936
+ // dropping user-specified resource and security restrictions.
937
+ for (const option of [
938
+ "memory",
939
+ "memorySwap",
940
+ "memoryReservation",
941
+ "user",
942
+ "seccompProfile",
943
+ "appArmorProfile",
944
+ "userNamespace",
945
+ "logDriver",
946
+ "stopSignal",
947
+ ] as const) {
948
+ if (news[option] !== undefined && news[option].trim().length === 0) {
949
+ return Effect.fail(
950
+ new InvalidContainerOptions({
951
+ message: `Docker.Container ${option} cannot be empty`,
952
+ }),
953
+ );
954
+ }
955
+ }
956
+ if (news.capDrop?.some((capability) => capability.trim().length === 0)) {
957
+ return Effect.fail(
958
+ new InvalidContainerOptions({
959
+ message: "Docker.Container capDrop cannot contain empty capabilities",
960
+ }),
961
+ );
962
+ }
963
+ if (news.groupAdd?.some((group) => group.trim().length === 0)) {
964
+ return Effect.fail(
965
+ new InvalidContainerOptions({
966
+ message: "Docker.Container groupAdd cannot contain empty groups",
967
+ }),
968
+ );
969
+ }
970
+ for (const [name, limit] of Object.entries(news.ulimits ?? {})) {
971
+ if (
972
+ !/^[a-z][a-z0-9_-]*$/.test(name) ||
973
+ !Number.isSafeInteger(limit.soft) ||
974
+ !Number.isSafeInteger(limit.hard) ||
975
+ limit.soft < -1 ||
976
+ limit.hard < -1 ||
977
+ (limit.hard !== -1 && (limit.soft === -1 || limit.soft > limit.hard))
978
+ ) {
979
+ return Effect.fail(
980
+ new InvalidContainerOptions({
981
+ message: `Docker.Container ulimits contains an invalid soft/hard limit for ${name}`,
982
+ }),
983
+ );
984
+ }
985
+ }
986
+ for (const [name, value] of Object.entries(news.logOptions ?? {})) {
987
+ if (!name.trim() || name.includes("=") || !value.trim()) {
988
+ return Effect.fail(
989
+ new InvalidContainerOptions({
990
+ message: `Docker.Container logOptions contains an invalid option: ${name}`,
991
+ }),
992
+ );
993
+ }
994
+ }
995
+ if (
996
+ news.oomScoreAdjustment !== undefined &&
997
+ (!Number.isInteger(news.oomScoreAdjustment) ||
998
+ news.oomScoreAdjustment < -1000 ||
999
+ news.oomScoreAdjustment > 1000)
1000
+ ) {
1001
+ return Effect.fail(
1002
+ new InvalidContainerOptions({
1003
+ message: "Docker.Container oomScoreAdjustment must be an integer from -1000 to 1000",
1004
+ }),
1005
+ );
1006
+ }
1007
+ if (news.memorySwap !== undefined && news.memory === undefined) {
1008
+ return Effect.fail(
1009
+ new InvalidContainerOptions({
1010
+ message: "Docker.Container memorySwap requires memory to be set",
1011
+ }),
1012
+ );
1013
+ }
1014
+ if (news.cpus !== undefined && (!Number.isFinite(news.cpus) || news.cpus <= 0)) {
1015
+ return Effect.fail(
1016
+ new InvalidContainerOptions({
1017
+ message: "Docker.Container cpus must be a finite, positive number",
1018
+ }),
1019
+ );
1020
+ }
1021
+ if (
1022
+ news.pidsLimit !== undefined &&
1023
+ (!Number.isInteger(news.pidsLimit) || (news.pidsLimit <= 0 && news.pidsLimit !== -1))
1024
+ ) {
1025
+ return Effect.fail(
1026
+ new InvalidContainerOptions({
1027
+ message: "Docker.Container pidsLimit must be a positive integer or -1 (unlimited)",
1028
+ }),
1029
+ );
1030
+ }
710
1031
  if (
711
1032
  isContainerNetworkMode(news.networkMode) &&
712
1033
  ((news.ports?.length ?? 0) > 0 || (news.networks?.length ?? 0) > 0)
@@ -728,6 +1049,41 @@ const validateContainerOptions = (news: ContainerProps) => {
728
1049
  }
729
1050
  targets.add(device.containerPath);
730
1051
  }
1052
+ const tmpfsTargets = new Set<string>();
1053
+ for (const mount of news.tmpfs ?? []) {
1054
+ if (mount.size !== undefined && mount.size.trim().length === 0) {
1055
+ return Effect.fail(
1056
+ new InvalidContainerOptions({
1057
+ message: `Docker.Container tmpfs size cannot be empty for ${mount.path}`,
1058
+ }),
1059
+ );
1060
+ }
1061
+ if (!mount.path.startsWith("/") || mount.path.includes(":") || mount.path.includes(",")) {
1062
+ return Effect.fail(
1063
+ new InvalidContainerOptions({
1064
+ message: `Docker.Container tmpfs target must be an absolute path: ${mount.path}`,
1065
+ }),
1066
+ );
1067
+ }
1068
+ if (tmpfsTargets.has(mount.path)) {
1069
+ return Effect.fail(
1070
+ new InvalidContainerOptions({
1071
+ message: `Docker.Container tmpfs contains duplicate target ${mount.path}`,
1072
+ }),
1073
+ );
1074
+ }
1075
+ if (
1076
+ mount.mode !== undefined &&
1077
+ (!Number.isInteger(mount.mode) || mount.mode < 0 || mount.mode > 0o7777)
1078
+ ) {
1079
+ return Effect.fail(
1080
+ new InvalidContainerOptions({
1081
+ message: `Docker.Container tmpfs mode is not a valid POSIX mode for ${mount.path}`,
1082
+ }),
1083
+ );
1084
+ }
1085
+ tmpfsTargets.add(mount.path);
1086
+ }
731
1087
  return Effect.void;
732
1088
  };
733
1089
 
package/src/Context.ts CHANGED
@@ -41,9 +41,19 @@ export interface Context extends Resource<
41
41
  docker?: string;
42
42
  },
43
43
  never,
44
- Providers
44
+ Providers,
45
+ ContextLookup
45
46
  > {}
46
47
 
48
+ /**
49
+ * Props for {@link Context.lookup}: find a Docker context this
50
+ * stack doesn't manage by its name.
51
+ */
52
+ export interface ContextLookup {
53
+ /** Context name. */
54
+ name: string;
55
+ }
56
+
47
57
  /**
48
58
  * A named Docker CLI context — a pointer to a Docker engine (local socket,
49
59
  * SSH host, or TCP endpoint) that other Docker resources deploy through.
@@ -89,6 +99,17 @@ export interface Context extends Resource<
89
99
  * });
90
100
  * ```
91
101
  *
102
+ * ### Looking up an existing context
103
+ * `Context.lookup` references a context Alchemy doesn't manage (created with
104
+ * the Docker CLI, Compose or another stack). It is read during plan/deploy,
105
+ * never created, updated or removed.
106
+ *
107
+ * **Example:** Deploy to an existing context
108
+ * ```typescript
109
+ * const remote = Docker.Context.lookup({ name: "prod-host" });
110
+ * const api = yield* Docker.Container("Api", { image: "nginx:alpine", context: remote });
111
+ * ```
112
+ *
92
113
  * @resource
93
114
  * @product Context
94
115
  */
@@ -107,6 +128,11 @@ export const ContextProvider = () =>
107
128
 
108
129
  return Context.Provider.of({
109
130
  list: () => Effect.succeed([]),
131
+ lookup: Effect.fn(function* ({ name }) {
132
+ const live = yield* inspect(name);
133
+ return live ? toContextAttributes(live) : undefined;
134
+ }),
135
+
110
136
  read: Effect.fn(function* ({ id, instanceId, olds, output }) {
111
137
  const name = yield* dockerPhysicalName(id, olds, instanceId);
112
138
  const live = yield* inspect(name);