diff --git a/Dockerfile b/Dockerfile index 8c84167..4645113 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ FROM ubuntu:26.04 ARG NODE_VERSION=26.7.0 -ARG TARGETARCH=amd64 +ARG TARGETARCH ENV NODE_ENV=production \ HOST=0.0.0.0 \ @@ -25,25 +25,33 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Ubuntu package manager. TARGETARCH keeps the image usable on the OCI # architectures supported by the Node.js release. RUN set -eux; \ - case "${TARGETARCH}" in \ + debian_arch="$(dpkg --print-architecture)"; \ + target_arch="${TARGETARCH:-${debian_arch}}"; \ + if [ "${target_arch}" != "${debian_arch}" ]; then \ + echo "TARGETARCH ${target_arch} does not match the Ubuntu target architecture ${debian_arch}" >&2; \ + exit 1; \ + fi; \ + case "${target_arch}" in \ amd64) node_arch='x64' ;; \ arm64) node_arch='arm64' ;; \ arm) node_arch='armv7l' ;; \ ppc64le) node_arch='ppc64le' ;; \ s390x) node_arch='s390x' ;; \ - *) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + *) echo "Unsupported TARGETARCH: ${target_arch}" >&2; exit 1 ;; \ esac; \ export DEBIAN_FRONTEND=noninteractive; \ apt-get update; \ apt-get upgrade -y; \ - apt-get install -y --no-install-recommends ca-certificates curl xz-utils; \ + apt-get install -y --no-install-recommends ca-certificates curl libatomic1 xz-utils; \ curl --fail --silent --show-error --location \ "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" \ --output /tmp/node.tar.xz; \ curl --fail --silent --show-error --location \ "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" \ --output /tmp/SHASUMS256.txt; \ - grep -F " node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" /tmp/SHASUMS256.txt | sha256sum --check --status -; \ + grep -F " node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" /tmp/SHASUMS256.txt | \ + sed "s# node-v${NODE_VERSION}-linux-${node_arch}.tar.xz# /tmp/node.tar.xz#" | \ + sha256sum --check --status -; \ tar --extract --file /tmp/node.tar.xz --directory /usr/local --strip-components=1; \ rm -f /tmp/node.tar.xz /tmp/SHASUMS256.txt; \ apt-get purge -y --auto-remove curl xz-utils; \ diff --git a/scripts/m0-smoke.ps1 b/scripts/m0-smoke.ps1 index 5f0cb36..64699cf 100644 --- a/scripts/m0-smoke.ps1 +++ b/scripts/m0-smoke.ps1 @@ -43,7 +43,7 @@ function Invoke-Compose { $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { $renderedArguments = $commandArguments -join ' ' - throw "Docker Compose command failed with exit code \${exitCode}: docker $renderedArguments" + throw "Docker Compose command failed with exit code ${exitCode}: docker $renderedArguments" } } @@ -63,7 +63,7 @@ function Get-ComposeOutput { $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { $renderedArguments = $commandArguments -join ' ' - throw "Docker Compose command failed with exit code \${exitCode}: docker $renderedArguments" + throw "Docker Compose command failed with exit code ${exitCode}: docker $renderedArguments" } return @($output) } @@ -104,6 +104,13 @@ function Write-ComposeFailureContext { } $statusArguments += @('ps', '-a') & docker @statusArguments + Write-Host 'Docker Compose logs:' + $logArguments = @('compose', '--project-name', $ProjectName, '--file', $resolvedComposeFile) + if (-not [string]::IsNullOrWhiteSpace($resolvedEnvFile)) { + $logArguments += @('--env-file', $resolvedEnvFile) + } + $logArguments += @('logs', '--no-color', '--tail', '100', 'gulogulo') + & docker @logArguments } catch { Write-Warning "Unable to collect Docker Compose status: $($_.Exception.Message)" diff --git a/src/foundation/config.test.mjs b/src/foundation/config.test.mjs index 8071166..464bf92 100644 --- a/src/foundation/config.test.mjs +++ b/src/foundation/config.test.mjs @@ -53,6 +53,26 @@ test('configuration defaults are versioned, deterministic, and secret-free', () assert.equal(JSON.stringify(first).includes('secret'), false); }); +test('the production process environment is accepted by the loader', () => { + const configuration = loadConfiguration(process.env, { configFilePath: null }); + + assert.equal(configuration.schemaVersion, CONFIG_SCHEMA_VERSION); + assert.equal(configuration.project.machineName, 'gulogulo'); +}); + +test('empty Compose secret-reference placeholders remain unset', () => { + const configuration = loadConfiguration( + { + LDAP_BIND_SECRET_REF: '', + POSTGRES_DSN_SECRET_REF: '', + }, + { configFilePath: null }, + ); + + assert.equal(configuration.ldap.bindSecretRef, null); + assert.equal(configuration.postgres.dsnSecretRef, null); +}); + test('environment variables override mounted file values with canonical names first', () => { withConfigFile( { diff --git a/src/runtime/config.mjs b/src/runtime/config.mjs index cbe1671..51a6609 100644 --- a/src/runtime/config.mjs +++ b/src/runtime/config.mjs @@ -243,6 +243,14 @@ function readSecretReference(value, name) { return readString(value, name, SECRET_REFERENCE_PATTERN); } +function readEnvironmentOptionalSecretReference(value, name) { + if (value === '') { + return null; + } + + return readSecretReference(value, name); +} + function readDistinguishedName(value, name) { return readString(value, name, DISTINGUISHED_NAME_PATTERN); } @@ -478,7 +486,7 @@ function buildConfiguration(fileConfiguration, environment) { applyEnvironmentValue(target, 'enabled', environment, variables.enabled, readEnvironmentBoolean); applyEnvironmentValue(target, 'url', environment, variables.url, readUrl); applyEnvironmentValue(target, 'startTls', environment, variables.startTls, readEnvironmentBoolean); - applyEnvironmentValue(target, 'bindSecretRef', environment, variables.bindSecretRef, readSecretReference); + applyEnvironmentValue(target, 'bindSecretRef', environment, variables.bindSecretRef, readEnvironmentOptionalSecretReference); applyEnvironmentValue(target, 'userBaseDn', environment, variables.userBaseDn, readDistinguishedName); applyEnvironmentValue(target, 'connectTimeoutMs', environment, variables.connectTimeoutMs, (value, name) => readEnvironmentInteger(value, name, 100, 120_000)); continue; @@ -491,7 +499,7 @@ function buildConfiguration(fileConfiguration, environment) { applyEnvironmentValue(target, 'database', environment, variables.database, (value, name) => readEnvironmentString(value, name, SAFE_NAME_PATTERN)); applyEnvironmentValue(target, 'user', environment, variables.user, (value, name) => readEnvironmentString(value, name, SAFE_NAME_PATTERN)); applyEnvironmentValue(target, 'sslMode', environment, variables.sslMode, (value, name) => readEnvironmentEnum(value, name, ['disable', 'allow', 'prefer', 'require', 'verify-ca', 'verify-full'])); - applyEnvironmentValue(target, 'dsnSecretRef', environment, variables.dsnSecretRef, readSecretReference); + applyEnvironmentValue(target, 'dsnSecretRef', environment, variables.dsnSecretRef, readEnvironmentOptionalSecretReference); applyEnvironmentValue(target, 'connectTimeoutMs', environment, variables.connectTimeoutMs, (value, name) => readEnvironmentInteger(value, name, 100, 120_000)); continue; } @@ -629,15 +637,23 @@ function attachContractMetadata(legacyConfig, fullConfig) { * is mandatory and fails closed when it cannot be read or validated. */ export function loadConfiguration(environment = process.env, options = {}) { - assertObject(environment, 'environment'); + // Node exposes process.env as an environment-backed object rather than a + // normal plain object. Copy the default process environment before schema + // validation so the production entry point and direct test callers use the + // same contract without weakening the configuration-file checks. + const effectiveEnvironment = environment === process.env + ? Object.fromEntries(Object.entries(environment)) + : environment; + + assertObject(effectiveEnvironment, 'environment'); assertObject(options, 'options'); - const resolvedFile = resolveConfigurationFile(environment, options); + const resolvedFile = resolveConfigurationFile(effectiveEnvironment, options); const fileConfiguration = readConfigurationFile(resolvedFile.path, { optional: resolvedFile.optional, readFile: options.readFile, }); - const configuration = buildConfiguration(fileConfiguration, environment); + const configuration = buildConfiguration(fileConfiguration, effectiveEnvironment); return deepFreeze(configuration); }