@malloy-publisher/server 0.9.0 → 0.9.1

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.
package/README.docker.md CHANGED
@@ -80,12 +80,26 @@ Every application file is root-owned, so the server cannot modify one in place.
80
80
 
81
81
  - `/publisher/`, the server root, where it creates `publisher.db`. Because it owns this directory, the server can also rename aside and replace any of its top-level entries: `package.json`, `bun.lock`, and the `packages/` and `node_modules/` directories. Such a change lasts as long as the container. Some storage drivers, Docker's default overlayfs among them, refuse to rename a directory that comes from an image layer, but that is the driver's limit, not the image's.
82
82
  - `/publisher/publisher_data/`.
83
+ - `/publisher/ducklake_data/`, when a DuckLake storage destination's `bucketUrl` points there.
83
84
  - `/home/bun/.duckdb/extensions/`, for an extension the image did not bake.
84
85
 
85
- What you mount has to be writable by uid 1000 too:
86
+ What you mount has to be writable by uid 1000 too. Per kind of mount:
86
87
 
87
- - **A new named volume on `/publisher/publisher_data`** works as-is. Docker seeds an empty named volume from the image's directory, ownership included, and that is the only writable mount point the image prepares.
88
- - **A new named volume anywhere else** starts owned by root, because the image has no directory there to copy ownership from. That includes a DuckLake storage destination whose `bucketUrl` is a local path. Prepare it yourself: build a derived image with `RUN mkdir -p /path && chown 1000:1000 /path` (new named volumes there are then seeded correctly), chown the volume once with `--user 0`, or bind-mount a host directory owned by uid 1000. DuckDB reports the unprepared case as `No such file or directory` (for example `Failed to create directory "/data/lake/main/daily_orders"`), not as `EACCES`. The one-time chown names the mount path twice, as the mount target and as chown's argument:
88
+ | Mount | The server | What to do |
89
+ | --- | --- | --- |
90
+ | A new named volume on `/publisher/publisher_data` or `/publisher/ducklake_data` | writes | Nothing: Docker seeds it from the image, owned by uid 1000. |
91
+ | A new named volume anywhere else | writes | `docker run --rm --user 0 --entrypoint chown -v <volume>:/path ms2data/malloy-publisher -R 1000:1000 /path`, once. |
92
+ | A volume an older, root-run image wrote to | writes | The same `chown`, once, before starting the new image. With Compose, `docker compose run --rm --no-deps --user 0 --entrypoint chown publisher -R 1000:1000 /publisher/publisher_data`. |
93
+ | A bind of a host directory the server writes | writes | `sudo chown -R 1000:1000 <dir>`, or keep your ownership and add an ACL, which needs no `sudo` on a directory you own: `setfacl -R -m u:1000:rwX -m d:u:1000:rwX <dir>`. |
94
+ | A bind of a host directory the server only reads (a package `location`, a directory of zips) | reads | Readable by others, `chmod -R o+rX <dir>`, or group-readable with `sudo chgrp -R 1000 <dir> && chmod -R g+rX <dir>`. Mount it `:ro`. |
95
+ | A single bound file (the config, a key file) | reads | Readable by others or by gid 1000; a `0600` file is not. See the key-file recipes below. Mount it `:ro`. |
96
+ | A Kubernetes PersistentVolume | writes | `fsGroup: 1000` and `fsGroupChangePolicy: OnRootMismatch` in the pod's `securityContext`. |
97
+ | A Kubernetes Secret or projected token | reads | `fsGroup: 1000` makes a `0600`-mounted file readable by the group. |
98
+
99
+ Before starting the server, the probe at the end of this section confirms a mount is usable as uid 1000. The detail behind each row:
100
+
101
+ - **A new named volume on `/publisher/publisher_data` or `/publisher/ducklake_data`** works as-is. Docker seeds an empty named volume from the image's directory, ownership included, and those are the two writable mount points the image prepares: `publisher_data` for packages and per-package sandboxes, `ducklake_data` for a DuckLake storage destination whose `bucketUrl` is a local path.
102
+ - **A new named volume anywhere else** starts owned by root, because the image has no directory there to copy ownership from. Prepare it yourself: build a derived image with `RUN mkdir -p /path && chown 1000:1000 /path` (new named volumes there are then seeded correctly), chown the volume once with `--user 0`, or bind-mount a host directory owned by uid 1000. DuckDB reports the unprepared case as `No such file or directory` (for example `Failed to create directory "/data/lake/main/daily_orders"`), not as `EACCES`. The one-time chown names the mount path twice, as the mount target and as chown's argument:
89
103
 
90
104
  ```bash
91
105
  docker run --rm --user 0 --entrypoint chown \
@@ -110,8 +124,35 @@ What you mount has to be writable by uid 1000 too:
110
124
 
111
125
  `docker volume ls` shows the volume's full name if you would rather use the `docker run` form.
112
126
 
113
- - **A bind mount** keeps the host directory's ownership. On Linux, `chown -R 1000:1000` the host directory. Docker Desktop on macOS and Windows maps ownership for you. Running the container as some other uid to match the host is not a substitute: `/home/bun` is private to uid 1000, so that uid cannot read the baked DuckDB extensions.
114
- - **A read-only mount**, such as the config file, only needs to be readable.
127
+ - **A bind mount** keeps the host directory's ownership. On Linux, `sudo chown -R 1000:1000` the host directory, or keep it yours and grant uid 1000 through a POSIX ACL, which also covers files created later and, unlike `chown`, needs no `sudo` on a directory you own:
128
+
129
+ ```bash
130
+ setfacl -R -m u:1000:rwX -m d:u:1000:rwX <dir>
131
+ ```
132
+
133
+ Docker Desktop on macOS and Windows maps ownership for you. Running the container as some other uid to match the host is not a substitute: `/home/bun` is private to uid 1000, so that uid cannot read the baked DuckDB extensions.
134
+ - **A Kubernetes PersistentVolume** is not seeded from the image, so even a new one on `/publisher/publisher_data` starts owned by root (an `emptyDir` is world-writable and needs nothing). Set `fsGroup: 1000` in the pod's `securityContext`, which makes the volume group-writable by gid 1000, and `fsGroupChangePolicy: OnRootMismatch` so the kubelet does not re-chown a large volume on every start. `fsGroup` is also what makes a mounted Secret key file, or a projected service-account token, readable by uid 1000 where the platform mounts them `0600`.
135
+ - **A read-only mount** only needs to be readable by uid 1000. That covers the config file, a package `location`, a directory of package zips (a `.zip` location is extracted into `publisher_data/`, never beside the archive), and the key file `GOOGLE_APPLICATION_CREDENTIALS` names. A key file bound from a host path that does not exist arrives as a directory, and the server says so. The usual trap is the key file itself: a `gcloud` application-default credentials file is `0600` and owned by you, so uid 1000 cannot read it through a bind on Linux, unless your own uid is 1000 (the first user on most Ubuntu installs), in which case nothing is needed. Otherwise bind a copy it can read, or grant the group. Both need root, because only root can hand a file to another uid or to a group you are not in:
136
+
137
+ ```bash
138
+ sudo install -o 1000 -g 1000 -m 0400 ~/.config/gcloud/application_default_credentials.json ./secrets/key.json
139
+ # or, keeping the original in place:
140
+ sudo chgrp 1000 key.json && chmod 0640 key.json
141
+ ```
142
+
143
+ For a directory you own, `chmod -R o+rX <dir>` needs no root; the `chgrp 1000` / `g+rX` equivalent does.
144
+ - **Ownership is right and the server still reports `EACCES`:** on SELinux hosts (Fedora, RHEL) a bind mount needs the `:z` (shared) or `:Z` (private) option, or every access is refused whatever the owner. Under rootless Docker, Podman, or `userns-remap`, uid 1000 in the container is a subordinate uid on the host, so a host `chown 1000` names the wrong owner; use `podman unshare chown -R 1000:1000 <dir>`, or the remapped uid.
145
+
146
+ To check a mount before starting the server, run the probe as the image's user:
147
+
148
+ ```bash
149
+ docker run --rm --entrypoint sh -v <mount> ms2data/malloy-publisher \
150
+ -c 'id; touch <path>/.probe && rm <path>/.probe && echo writable'
151
+ ```
152
+
153
+ For a read-only mount, the same with `cat <file> >/dev/null && echo readable`.
154
+
155
+ When the server cannot make a write it needs, the response names the errno (`EACCES: permission denied, mkdir '…'`) rather than answering a bare `Internal server error.`. A package that cannot be mounted at boot, or added at runtime, is listed under `loadErrors` in `GET /api/v0/status` with that message, until it is added successfully or deleted. If the server cannot start at all, for example on a read-only root filesystem or with a config file it cannot read, `/api/v0/status` stays `initializing` and `initError` says why.
115
156
 
116
157
  If you cannot change the ownership yet, `--user 0` runs the server as root, as earlier images did. The image sets `HOME=/home/bun`, so a root run still finds the baked DuckDB extensions.
117
158
 
@@ -2876,11 +2876,27 @@ components:
2876
2876
  environments list as intended. Absent once any environment exists, including one created
2877
2877
  at runtime through the API, and absent when a config was found but
2878
2878
  lists no environments.
2879
+ initError:
2880
+ type: string
2881
+ readOnly: true
2882
+ description:
2883
+ Present only when initialization failed, and says why. Such a
2884
+ server stays up but never becomes ready, so operationalState stays
2885
+ initializing; without this field that reads as a server still
2886
+ starting. Typical causes are a config file the server cannot read
2887
+ or parse, and a server root it cannot write (where it creates
2888
+ publisher.db), such as a read-only root filesystem. A package or
2889
+ environment that fails to load is reported under loadErrors
2890
+ instead. Fix the cause and restart.
2879
2891
  loadErrors:
2880
2892
  type: array
2881
2893
  readOnly: true
2882
2894
  description:
2883
- Configured packages and environments that did not load, and why. A
2895
+ Configured packages and environments that did not load, and why,
2896
+ including a package add through the API that failed on the
2897
+ server's side, carrying the same message the add was answered
2898
+ with (an entry cleared by a later successful add of that package,
2899
+ or by deleting it). A
2884
2900
  load failure is not fatal to the server, so operationalState stays
2885
2901
  serving and the affected packages are absent from environments,
2886
2902
  unless the entry carries stale = true, in which case the package is
@@ -13058,9 +13058,54 @@ function logInternalFailure(summary, error, level = "error") {
13058
13058
  }
13059
13059
  });
13060
13060
  }
13061
- function internalErrorToHttpError(error) {
13061
+ function filesystemAccessFailure(error) {
13062
+ let current = error;
13063
+ for (let depth = 0;depth < 8 && current instanceof Error; depth++) {
13064
+ const errno = current;
13065
+ if (typeof errno.code === "string" && errno.code in FILESYSTEM_ACCESS_DESCRIPTIONS && typeof errno.syscall === "string") {
13066
+ return errno;
13067
+ }
13068
+ current = errno.cause;
13069
+ }
13070
+ return;
13071
+ }
13072
+ function describeFilesystemAccessFailure(errno) {
13073
+ const code = errno.code ?? "";
13074
+ const target = errno.path ? ` '${errno.path}'` : "";
13075
+ return `${code}: ${FILESYSTEM_ACCESS_DESCRIPTIONS[code] ?? code}, ${errno.syscall}${target}`;
13076
+ }
13077
+ function messageWithFilesystemCause(error) {
13078
+ const message = error instanceof Error ? error.message : String(error);
13079
+ const access = filesystemAccessFailure(error);
13080
+ if (!access || message.includes(access.code ?? ""))
13081
+ return message;
13082
+ return `${message}: ${describeFilesystemAccessFailure(access)}`;
13083
+ }
13084
+ function errnoWireFields(error) {
13085
+ const { code, syscall, path } = error;
13086
+ if (typeof code !== "string")
13087
+ return;
13088
+ return {
13089
+ code,
13090
+ ...typeof syscall === "string" ? { syscall } : {},
13091
+ ...typeof path === "string" ? { path } : {}
13092
+ };
13093
+ }
13094
+ function internalErrorToHttpError(error, { log = true } = {}) {
13095
+ const logInternal = (...args) => {
13096
+ if (log)
13097
+ logInternalFailure(...args);
13098
+ };
13099
+ const access = filesystemAccessFailure(error);
13100
+ if (access) {
13101
+ logInternal("Filesystem access refused", error, "warn");
13102
+ return httpError(500, `The server cannot access a path it needs (${describeFilesystemAccessFailure(access)}). ` + `Give the user the server runs as access to it.`);
13103
+ }
13062
13104
  if (error instanceof BadRequestError) {
13063
13105
  return httpError(400, error.message);
13106
+ } else if (error instanceof ServerConfigurationError) {
13107
+ logInternal("Server configuration error", error, "warn");
13108
+ return httpError(500, error.message);
13064
13109
  } else if (error instanceof FrozenConfigError) {
13065
13110
  return httpError(403, error.message);
13066
13111
  } else if (error instanceof AccessDeniedError) {
@@ -13106,7 +13151,7 @@ function internalErrorToHttpError(error) {
13106
13151
  if (error.callerSafe) {
13107
13152
  return httpError(502, error.message);
13108
13153
  }
13109
- logInternalFailure("Upstream connection error", error, "warn");
13154
+ logInternal("Upstream connection error", error, "warn");
13110
13155
  return httpError(502, GENERIC_UPSTREAM_MESSAGE);
13111
13156
  } else if (error instanceof MaterializationNotFoundError) {
13112
13157
  return httpError(404, error.message);
@@ -13117,7 +13162,7 @@ function internalErrorToHttpError(error) {
13117
13162
  } else if (error instanceof WriteConflictError) {
13118
13163
  return httpError(409, error.message);
13119
13164
  } else if (error instanceof WriteRolledBackError) {
13120
- logInternalFailure("Dashboard write rolled back", error, "warn");
13165
+ logInternal("Dashboard write rolled back", error, "warn");
13121
13166
  return httpError(500, error.message);
13122
13167
  } else if (error instanceof ServiceUnavailableError) {
13123
13168
  return httpError(503, error.message);
@@ -13128,7 +13173,7 @@ function internalErrorToHttpError(error) {
13128
13173
  } else if (error instanceof NotImplementedError) {
13129
13174
  return httpError(501, error.message);
13130
13175
  } else {
13131
- logInternalFailure("Unhandled internal error", error);
13176
+ logInternal("Unhandled internal error", error);
13132
13177
  return httpError(500, GENERIC_INTERNAL_MESSAGE);
13133
13178
  }
13134
13179
  }
@@ -13150,10 +13195,15 @@ function toQueryTextProblem(problem) {
13150
13195
  ...problem.at ? { at: { range: problem.at.range } } : {}
13151
13196
  };
13152
13197
  }
13153
- var GENERIC_INTERNAL_MESSAGE = "Internal server error.", GENERIC_UPSTREAM_MESSAGE = "Upstream connection error.", MAX_LOGGED_DETAIL_CHARS = 2000, NotImplementedError, BadRequestError, InvalidArgumentError, CompileRefusedError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, DashboardNotFoundError, ConnectionNotFoundError, TableNotFoundError, ConnectionError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, PackageManifestError, MaterializationEligibilityError, PublisherConfigError, FrozenConfigError, AccessDeniedError, QueryCompileError, NotQueryableError, OffSurfaceError, MaterializationNotFoundError, MaterializationConflictError, WriteConflictError, WriteRolledBackError, InvalidStateTransitionError, ServiceUnavailableError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
13198
+ var GENERIC_INTERNAL_MESSAGE = "Internal server error.", GENERIC_UPSTREAM_MESSAGE = "Upstream connection error.", MAX_LOGGED_DETAIL_CHARS = 2000, FILESYSTEM_ACCESS_DESCRIPTIONS, NotImplementedError, BadRequestError, InvalidArgumentError, CompileRefusedError, EnvironmentNotFoundError, PackageNotFoundError, ModelNotFoundError, DashboardNotFoundError, ConnectionNotFoundError, TableNotFoundError, ConnectionError, DestinationNotFoundError, ConnectionAuthError, UnsupportedCatalogFormatError, ModelCompilationError, PackageManifestError, MaterializationEligibilityError, PublisherConfigError, ServerConfigurationError, FrozenConfigError, AccessDeniedError, QueryCompileError, NotQueryableError, OffSurfaceError, MaterializationNotFoundError, MaterializationConflictError, WriteConflictError, WriteRolledBackError, InvalidStateTransitionError, ServiceUnavailableError, PayloadTooLargeError, ResponseUnserializableError, QueryTimeoutError;
13154
13199
  var init_errors = __esm(() => {
13155
13200
  init_constants();
13156
13201
  init_logger();
13202
+ FILESYSTEM_ACCESS_DESCRIPTIONS = {
13203
+ EACCES: "permission denied",
13204
+ EPERM: "operation not permitted",
13205
+ EROFS: "read-only file system"
13206
+ };
13157
13207
  NotImplementedError = class NotImplementedError extends Error {
13158
13208
  constructor(message) {
13159
13209
  super(message);
@@ -13174,8 +13224,8 @@ var init_errors = __esm(() => {
13174
13224
  }
13175
13225
  };
13176
13226
  PackageNotFoundError = class PackageNotFoundError extends Error {
13177
- constructor(message) {
13178
- super(message);
13227
+ constructor(message, options) {
13228
+ super(message, options);
13179
13229
  }
13180
13230
  };
13181
13231
  ModelNotFoundError = class ModelNotFoundError extends Error {
@@ -13246,6 +13296,12 @@ var init_errors = __esm(() => {
13246
13296
  this.cause = cause;
13247
13297
  }
13248
13298
  };
13299
+ ServerConfigurationError = class ServerConfigurationError extends Error {
13300
+ constructor(message) {
13301
+ super(message);
13302
+ this.name = "ServerConfigurationError";
13303
+ }
13304
+ };
13249
13305
  FrozenConfigError = class FrozenConfigError extends Error {
13250
13306
  constructor(message = `Publisher config can't be updated when ${PUBLISHER_CONFIG_NAME} has { "frozenConfig": true }`) {
13251
13307
  super(message);
@@ -13303,8 +13359,8 @@ var init_errors = __esm(() => {
13303
13359
  }
13304
13360
  };
13305
13361
  ServiceUnavailableError = class ServiceUnavailableError extends Error {
13306
- constructor(message) {
13307
- super(message);
13362
+ constructor(message, options) {
13363
+ super(message, options);
13308
13364
  }
13309
13365
  };
13310
13366
  PayloadTooLargeError = class PayloadTooLargeError extends Error {
@@ -13723,6 +13779,76 @@ var init_data_styles = __esm(() => {
13723
13779
  init_logger();
13724
13780
  });
13725
13781
 
13782
+ // src/package_load/error_wire.ts
13783
+ import { MalloyError as MalloyError2 } from "@malloydata/malloy";
13784
+ function serializeError(error) {
13785
+ const serialized = serializeErrorShape(error);
13786
+ const errno = error instanceof Error ? errnoWireFields(error) : undefined;
13787
+ return errno ? { ...serialized, errno } : serialized;
13788
+ }
13789
+ function serializeErrorShape(error) {
13790
+ if (error instanceof MalloyError2) {
13791
+ return {
13792
+ name: error.name,
13793
+ message: error.message,
13794
+ stack: error.stack,
13795
+ malloyProblems: error.problems,
13796
+ isCompilationError: true
13797
+ };
13798
+ }
13799
+ if (error instanceof ModelCompilationError) {
13800
+ return {
13801
+ name: error.name,
13802
+ message: error.message,
13803
+ stack: error.stack,
13804
+ isCompilationError: true
13805
+ };
13806
+ }
13807
+ if (error instanceof PackageManifestError) {
13808
+ return {
13809
+ name: error.name,
13810
+ message: error.message,
13811
+ stack: error.stack,
13812
+ isManifestError: true
13813
+ };
13814
+ }
13815
+ if (error instanceof Error) {
13816
+ return {
13817
+ name: error.name,
13818
+ message: error.message,
13819
+ stack: error.stack
13820
+ };
13821
+ }
13822
+ return { name: "Error", message: String(error) };
13823
+ }
13824
+ function deserializeError(serialized) {
13825
+ const err = new Error(serialized.message);
13826
+ err.name = serialized.name;
13827
+ if (serialized.stack)
13828
+ err.stack = serialized.stack;
13829
+ if (serialized.errno)
13830
+ Object.assign(err, serialized.errno);
13831
+ if (serialized.malloyProblems) {
13832
+ err.problems = serialized.malloyProblems;
13833
+ }
13834
+ if (serialized.isCompilationError) {
13835
+ const wrapped = new ModelCompilationError(err);
13836
+ if (serialized.stack)
13837
+ wrapped.stack = serialized.stack;
13838
+ return wrapped;
13839
+ }
13840
+ if (serialized.isManifestError) {
13841
+ const manifestError = new PackageManifestError(serialized.message);
13842
+ if (serialized.stack)
13843
+ manifestError.stack = serialized.stack;
13844
+ return manifestError;
13845
+ }
13846
+ return err;
13847
+ }
13848
+ var init_error_wire = __esm(() => {
13849
+ init_errors();
13850
+ });
13851
+
13726
13852
  // src/service/annotations.ts
13727
13853
  import { Annotations } from "@malloydata/malloy";
13728
13854
  function isReservedRoute(route) {
@@ -23935,6 +24061,7 @@ init_constants();
23935
24061
  init_authorize_metrics();
23936
24062
  init_data_styles();
23937
24063
  init_errors();
24064
+ init_error_wire();
23938
24065
  init_authorize();
23939
24066
  init_gate_classification();
23940
24067
  init_gate_dimension();
@@ -23943,7 +24070,6 @@ import {
23943
24070
  contextOverlay,
23944
24071
  isSourceDef as isSourceDef7,
23945
24072
  MalloyConfig,
23946
- MalloyError as MalloyError2,
23947
24073
  modelDefToModelInfo as modelDefToModelInfo2,
23948
24074
  Runtime
23949
24075
  } from "@malloydata/malloy";
@@ -24722,48 +24848,6 @@ async function loadPackage(job) {
24722
24848
  }
24723
24849
  };
24724
24850
  }
24725
- function serializeError(error) {
24726
- if (error instanceof MalloyError2) {
24727
- return {
24728
- name: error.name,
24729
- message: error.message,
24730
- stack: error.stack,
24731
- malloyProblems: error.problems,
24732
- isCompilationError: true
24733
- };
24734
- }
24735
- if (error instanceof ModelCompilationError) {
24736
- return {
24737
- name: error.name,
24738
- message: error.message,
24739
- stack: error.stack,
24740
- isCompilationError: true
24741
- };
24742
- }
24743
- if (error instanceof PackageManifestError) {
24744
- return {
24745
- name: error.name,
24746
- message: error.message,
24747
- stack: error.stack,
24748
- isManifestError: true
24749
- };
24750
- }
24751
- if (error instanceof Error) {
24752
- return {
24753
- name: error.name,
24754
- message: error.message,
24755
- stack: error.stack
24756
- };
24757
- }
24758
- return { name: "Error", message: String(error) };
24759
- }
24760
- function deserializeError(serialized) {
24761
- const err = new Error(serialized.message);
24762
- err.name = serialized.name;
24763
- if (serialized.stack)
24764
- err.stack = serialized.stack;
24765
- return err;
24766
- }
24767
24851
  var shuttingDown = false;
24768
24852
  var inFlightJobs = new Set;
24769
24853
  port.on("message", (message) => {