diff --git a/.env.example b/.env.example index 5c51f5ae20..7eaf8973c3 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,3 @@ ZENDESK_KEY= GTM_ID= BRANCH=optional (sets the edit path) - -FEATURE_DEV_FLAG=false -FEATURE_NEW_PRODUCTS_FLAG=false \ No newline at end of file diff --git a/.github/workflows/check-formatting.yml b/.github/workflows/check-formatting.yml index 3f33c754e5..4681b3a1a2 100644 --- a/.github/workflows/check-formatting.yml +++ b/.github/workflows/check-formatting.yml @@ -18,10 +18,19 @@ jobs: node-version: '20' cache: 'npm' - - name: Install dependencies - run: npm ci + # The repo .npmrc is credential-less; inject feed creds into ~/.npmrc + - name: Authenticate to codat-npm feed + run: | + { + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:username=codat" + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:_password=${NPM_TOKEN}" + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:email=npm-requires-email@example.com" + } >> ~/.npmrc env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Install dependencies + run: npm ci - name: Check formatting run: npm run format:js:check \ No newline at end of file diff --git a/.github/workflows/check-markdown-lint.yml b/.github/workflows/check-markdown-lint.yml index 0f3ff9be37..ab2aa7404b 100644 --- a/.github/workflows/check-markdown-lint.yml +++ b/.github/workflows/check-markdown-lint.yml @@ -29,10 +29,19 @@ jobs: node-version: '20' cache: 'npm' - - name: Install dependencies - run: npm ci + # The repo .npmrc is credential-less; inject feed creds into ~/.npmrc + - name: Authenticate to codat-npm feed + run: | + { + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:username=codat" + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:_password=${NPM_TOKEN}" + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:email=npm-requires-email@example.com" + } >> ~/.npmrc env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Install dependencies + run: npm ci + - name: Check markdown formatting with Prettier run: npm run format:mdx:check \ No newline at end of file diff --git a/.github/workflows/check-spelling.yml b/.github/workflows/check-spelling.yml index d7039d1ac1..894fb895be 100644 --- a/.github/workflows/check-spelling.yml +++ b/.github/workflows/check-spelling.yml @@ -18,10 +18,19 @@ jobs: node-version: '20' cache: 'npm' - - name: Install dependencies - run: npm install -g cspell + # The repo .npmrc is credential-less; inject feed creds into ~/.npmrc + - name: Authenticate to codat-npm feed + run: | + { + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:username=codat" + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:_password=${NPM_TOKEN}" + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:email=npm-requires-email@example.com" + } >> ~/.npmrc env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Install dependencies + run: npm install -g cspell - name: Run spell check run: cspell "**/*.md" "**/*.mdx" --config cspell.json \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9164428910..b59d4c3c5a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -29,19 +29,27 @@ jobs: node-version: 24 cache: npm - - name: Install dependencies - run: npm ci + # The repo .npmrc is credential-less (the K8s image build authenticates + # via ADO npmAuthenticate); GH-hosted builds inject creds here instead. + - name: Authenticate to codat-npm feed + run: | + { + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:username=codat" + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:_password=${NPM_TOKEN}" + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:email=npm-requires-email@example.com" + } >> ~/.npmrc env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Install dependencies + run: npm ci + - name: Build site run: npm run build env: ZENDESK_KEY: ${{ secrets.ZENDESK_KEY }} AMPLITUDE_API_KEY: ${{ secrets.AMPLITUDE_API_KEY }} GTM_ID: ${{ vars.GTM_ID }} - FEATURE_DEV_FLAG: ${{ vars.FEATURE_DEV_FLAG }} - FEATURE_NEW_PRODUCTS_FLAG: ${{ vars.FEATURE_NEW_PRODUCTS_FLAG }} - uses: actions/configure-pages@v6 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 45aaf931ff..6415c237a2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -25,20 +25,27 @@ jobs: node-version: 24 cache: npm - # The ADO registry in .npmrc requires auth even to install, so PR - # builds need NPM_TOKEN too (fork PRs don't get secrets and will fail) - - name: Install dependencies - run: npm ci + # The ADO registry requires auth even to install; the repo .npmrc is + # credential-less so creds go into ~/.npmrc here (fork PRs don't get + # secrets and will fail) + - name: Authenticate to codat-npm feed + run: | + { + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:username=codat" + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:_password=${NPM_TOKEN}" + echo "//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:email=npm-requires-email@example.com" + } >> ~/.npmrc env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Install dependencies + run: npm ci + - name: Build site run: npm run build env: # plugin-google-gtag requires a trackingID, so PR builds need it too GTM_ID: ${{ vars.GTM_ID }} - FEATURE_DEV_FLAG: ${{ vars.FEATURE_DEV_FLAG }} - FEATURE_NEW_PRODUCTS_FLAG: ${{ vars.FEATURE_NEW_PRODUCTS_FLAG }} # Link checking reuses the build above: linkinator serves ./build on an # ephemeral localhost port, so no deployed preview environment is needed. diff --git a/.npmrc b/.npmrc index 8df58e10f6..8ac16dab13 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,2 @@ registry=https://pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/ -always-auth=true save-exact=true -//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:username=codat -//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:_password=${NPM_TOKEN} -//pkgs.dev.azure.com/codat/Codat/_packaging/codat-npm/npm/registry/:email=engineering@codat.io \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..573fcd06b9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM nginx:1.29.6-alpine-slim AS final + +COPY --from=build /src/build /usr/share/nginx/html +COPY --from=build /src/nginx.conf /etc/nginx/nginx.conf + +RUN rm /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/deployment/build.azure-pipelines.yaml b/deployment/build.azure-pipelines.yaml new file mode 100644 index 0000000000..c349e42fbd --- /dev/null +++ b/deployment/build.azure-pipelines.yaml @@ -0,0 +1,83 @@ +name: $(Date:yyyy.M.dd).$(Rev:r) + +trigger: + branches: + include: + - 'main' + paths: + exclude: [] + +pr: + branches: + include: + - 'main' + +resources: + repositories: + - repository: 'templates' + type: 'git' + name: 'Codat/YAMLBuildTemplates' + +# Array values as Variables not supported by ADO +parameters: + - name: ImagesToPublish + type: object + default: + - 'codat.docs.ui' + + - name: DeploymentEnvironments + type: object + default: + - name: integration + shortCode: intg + pool: + name: codat-intg-managed-devops-pool-linux + demands: + - CustomCapabilities -equals $(Build.Repository.Name) + registryName: codatintgregistry + + - name: production + shortCode: prod + pool: codat-prod-managed-devops-pool-linux + registryName: codatprodregistry + +stages: + - stage: 'build' + pool: + name: 'codat-intg-managed-devops-pool-linux' + demands: + - CustomCapabilities -equals $(Build.Repository.Name) + jobs: + - template: Helm/build.frontend.job.yaml@templates + parameters: + ImagesToPublish: ${{ parameters.ImagesToPublish }} + Version: '$(Build.BuildNumber)' + BuildFolder: deployment + # Images are Git LFS-tracked; a plain checkout leaves 130-byte + # pointer files and Docusaurus only WARNS on unreadable images, + # so the build would stay green while shipping broken images. + GitLfs: true + # Build-time site config, fetched on the agent (the vault blocks + # public network access, so a KV-linked variable group can't read + # it — only the pool agents can, via the private endpoint). KV + # secret names can't contain underscores, hence the hyphenated + # $() references; the bake file maps them to the underscored args. + KeyVaultName: codat-integration + KeyVaultSecretsFilter: 'docs-zendesk-key,docs-amplitude-api-key,docs-gtm-id' + AdditionalBakeEnvironment: + ZENDESK_KEY: $(docs-zendesk-key) + AMPLITUDE_API_KEY: $(docs-amplitude-api-key) + GTM_ID: $(docs-gtm-id) + + - ${{ if not(in( variables['Build.Reason'], 'PullRequest' )) }}: + - ${{ each env in parameters.DeploymentEnvironments }}: + - ${{ if or(eq(variables['Build.SourceBranch'], 'refs/heads/main'), ne(env.name, 'production')) }}: + - stage: ${{ env.name }} + pool: ${{ env.pool }} + jobs: + - template: Helm/deploy.job.yaml@templates + parameters: + Environment: ${{ env.name }} + RegistryName: ${{ env.registryName }} + ImagesToPublish: ${{ parameters.ImagesToPublish }} + ArgoApplicationName: docs diff --git a/deployment/charts/Chart.yaml b/deployment/charts/Chart.yaml new file mode 100644 index 0000000000..c5e543599c --- /dev/null +++ b/deployment/charts/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: 'v2' +name: 'codat.docs' +type: 'application' +version: '0.0.1' # this is overwritten by the pipeline +dependencies: + - name: 'codat-classic-app' + version: '>=2.6.1' + repository: 'oci://codatintgregistry.azurecr.io/base-charts' diff --git a/deployment/charts/values-intg.yaml b/deployment/charts/values-intg.yaml new file mode 100644 index 0000000000..9bd9b0f065 --- /dev/null +++ b/deployment/charts/values-intg.yaml @@ -0,0 +1,19 @@ +codat-classic-app: + components: + - name: ui + image: codatintgregistry.azurecr.io/codat.docs.ui + routing: + - hosts: ["docs-integration.codat.io"] + containerPort: 80 + statusPath: "/" + horizontalPodAutoscaling: + minReplicas: 1 + maxReplicas: 2 + podDisruptionBudget: + minAvailable: 0 + resources: + requests: + memory: 20Mi + cpu: 10m + limits: + memory: 100Mi diff --git a/deployment/charts/values-prod.yaml b/deployment/charts/values-prod.yaml new file mode 100644 index 0000000000..db5a914b59 --- /dev/null +++ b/deployment/charts/values-prod.yaml @@ -0,0 +1,21 @@ +codat-classic-app: + components: + - name: ui + image: codatprodregistry.azurecr.io/codat.docs.ui + routing: + - hosts: ["docs.codat.io"] + containerPort: 80 + statusPath: "/" + # High-traffic, client-facing (unlike legal): sized up per EXP-2106. + # Review requests/limits against real usage after a week in prod. + horizontalPodAutoscaling: + minReplicas: 2 + maxReplicas: 4 + podDisruptionBudget: + minAvailable: 1 + resources: + requests: + memory: 50Mi + cpu: 25m + limits: + memory: 200Mi diff --git a/deployment/charts/values.yaml b/deployment/charts/values.yaml new file mode 100644 index 0000000000..88a1573e7b --- /dev/null +++ b/deployment/charts/values.yaml @@ -0,0 +1,7 @@ +codat-classic-app: + appVersion: 0.0.0 + metadata: + serviceName: docs + owningTeam: &team Pepper + env: + Codat_This_OwningTeam: *team diff --git a/deployment/docker/build.dockerfile b/deployment/docker/build.dockerfile new file mode 100644 index 0000000000..6827e7fe43 --- /dev/null +++ b/deployment/docker/build.dockerfile @@ -0,0 +1,32 @@ +ARG DOCKER_REGISTRY + +FROM ${DOCKER_REGISTRY:-}base-images/node-build:24-bookworm-slim AS build + +WORKDIR /src + +COPY package.json . +COPY package-lock.json . +# Credential-less in the repo; the ADO pipeline's npm authenticate step injects +# feed credentials into it before the bake (same mechanism as legal / admin-ui). +# Local builds need your own authenticated .npmrc. +COPY .npmrc . + +RUN npm ci + +# .git IS dockerignored: this site doesn't use showLastUpdateAuthor/Time, so +# the build never shells out to git (unlike legal). +COPY . . + +ENV CI=true + +# Build-time site config, baked into the static output (docusaurus.config.js +# customFields). Values arrive via docker-bake.hcl from the pipeline env; +# BRANCH is deliberately unset — editUrl falls back to main. +ARG ZENDESK_KEY +ARG AMPLITUDE_API_KEY +ARG GTM_ID +ENV ZENDESK_KEY=${ZENDESK_KEY} \ + AMPLITUDE_API_KEY=${AMPLITUDE_API_KEY} \ + GTM_ID=${GTM_ID} + +RUN npm run build diff --git a/deployment/docker/build.dockerfile.dockerignore b/deployment/docker/build.dockerfile.dockerignore new file mode 100644 index 0000000000..79c1325840 --- /dev/null +++ b/deployment/docker/build.dockerfile.dockerignore @@ -0,0 +1,14 @@ +# .git is excluded: no showLastUpdateAuthor/Time in docusaurus.config.js, so +# the build never reads git history (legal keeps it; we don't need it). +.git +.vscode +.dockerignore +.gitignore +.env +build +node_modules +Dockerfile +README.md +deployment +.github +code_utils diff --git a/deployment/docker/docker-bake.hcl b/deployment/docker/docker-bake.hcl new file mode 100644 index 0000000000..832392824c --- /dev/null +++ b/deployment/docker/docker-bake.hcl @@ -0,0 +1,46 @@ +variable VERSION { + default = "dev" +} + +variable DOCKER_REGISTRY { + default = "codatintgregistry.azurecr.io/" +} + +# Build-time site config; bake reads these from the pipeline step's env +# (values come from the codat-docs-build variable group). +variable ZENDESK_KEY { + default = "" +} + +variable AMPLITUDE_API_KEY { + default = "" +} + +variable GTM_ID { + default = "" +} + +group "default" { + targets = [ + "build", + "app" + ] +} + +target "build" { + context = "./" + dockerfile = "deployment/docker/build.dockerfile" + args = { + DOCKER_REGISTRY = "${DOCKER_REGISTRY}" + ZENDESK_KEY = "${ZENDESK_KEY}" + AMPLITUDE_API_KEY = "${AMPLITUDE_API_KEY}" + GTM_ID = "${GTM_ID}" + } + tags = [ "codat.docs.build:${VERSION}" ] +} + +target "app" { + contexts = { "build" = "target:build" } + dockerfile = "Dockerfile" + tags = [ "codat.docs.ui:${VERSION}" ] +} diff --git a/docs/auth-flow/build/build-your-own-authorization-journey.md b/docs/auth-flow/build/build-your-own-authorization-journey.md index aa24e5f660..d2859e5b12 100644 --- a/docs/auth-flow/build/build-your-own-authorization-journey.md +++ b/docs/auth-flow/build/build-your-own-authorization-journey.md @@ -130,8 +130,6 @@ The end user would need to authorize a new data connection if you wish to view n :::tip Codat's connection management -Codat is releasing a low-code embeddable UI component for connection management. Please [let us know](https://forms.gle/d1zuh2iHBLJCNCsj9) if you are interested in using it. - For a detailed best practices article on connection management, see [Connection management](/auth-flow/optimize/connection-management). ::: diff --git a/docusaurus.config.js b/docusaurus.config.js index dd466c30f9..0ff1d23173 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -113,8 +113,6 @@ const config = { customFields: { DEVELOPMENT: process.env.NODE_ENV === "development", ZENDESK_KEY: process.env.ZENDESK_KEY, - FEATURE_DEV_FLAG: process.env.FEATURE_DEV_FLAG, - FEATURE_NEW_PRODUCTS_FLAG: process.env.FEATURE_NEW_PRODUCTS_FLAG, AMPLITUDE_API_KEY: process.env.AMPLITUDE_API_KEY, }, diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000000..96bf54610d --- /dev/null +++ b/nginx.conf @@ -0,0 +1,84 @@ +user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + + gzip on; + gzip_types text/plain text/css text/javascript application/javascript application/json application/x-javascript text/xml application/xml application/xml+rss; + gzip_min_length 256; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + + include /etc/nginx/conf.d/*.conf; + + server { + listen 80; + server_name localhost; + server_tokens off; + port_in_redirect off; + + # `always` so the headers are also sent on 404s (add_header only + # covers 2xx/3xx by default, and unknown paths here are real 404s). + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Permitted-Cross-Domain-Policies "none" always; + add_header Referrer-Policy "origin-when-cross-origin" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + + root /usr/share/nginx/html; + index index.html; + + # Static Docusaurus output: no SPA fallback — unknown paths get the + # generated 404 page with a real 404 status. + error_page 404 /404.html; + + location / { + absolute_redirect off; + try_files $uri $uri/ =404; + } + + # portal-ui's "What's New" dropdown (useParseWhatsNewFeed) fetches this + # cross-origin from app.codat.io. GitHub Pages sent + # Access-Control-Allow-Origin: * on everything; nginx must opt in. + # add_header in a location suppresses the inherited server-level set, + # so the security headers are repeated here. + location = /updates/rss.xml { + add_header Access-Control-Allow-Origin "*" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Permitted-Cross-Domain-Policies "none" always; + add_header Referrer-Policy "origin-when-cross-origin" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + } + + # Disable .git directory + location ~ /\.git { + deny all; + access_log off; + log_not_found off; + } + } +} diff --git a/src/theme/BlogLayout/index.tsx b/src/theme/BlogLayout/index.tsx index 1f505bc8ed..344039dade 100644 --- a/src/theme/BlogLayout/index.tsx +++ b/src/theme/BlogLayout/index.tsx @@ -12,7 +12,6 @@ import Layout from "@theme/Layout"; import BlogSidebar from "@theme/BlogSidebar"; import Navbar from "@theme/Navbar"; -import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { useLocation } from "@docusaurus/router"; import styles from "./styles.module.scss"; @@ -23,7 +22,6 @@ export default function BlogLayout(props: Props): JSX.Element { const { sidebar, toc, children, ...layoutProps } = props; const hasSidebar = sidebar && sidebar.items.length > 0; - const { siteConfig } = useDocusaurusContext(); const location = useLocation(); const isUpdatesPage = @@ -32,21 +30,6 @@ export default function BlogLayout(props: Props): JSX.Element { return ( - {siteConfig.customFields?.FEATURE_DEV_FLAG === "true" && ( -
- This is a staging build of the docs -
- )} - - {siteConfig.customFields?.FEATURE_NEW_PRODUCTS_FLAG === "true" && ( -
- - We've reorganized our products to make building with Codat easier - than ever - -
- )} -
diff --git a/src/theme/BlogLayout/styles.module.scss b/src/theme/BlogLayout/styles.module.scss index c14f2314c1..af08e6b737 100644 --- a/src/theme/BlogLayout/styles.module.scss +++ b/src/theme/BlogLayout/styles.module.scss @@ -42,27 +42,4 @@ .article { padding: 1em; max-width: 1000px; -} - -.devFlag { - width: 100%; - text-align: center; - padding: 4px 8px; - color: black; - background-color: orange; -} - -.newFlagPositive { - width: 100%; - text-align: center; - padding: 4px 8px; - background-color: var(--button-primary-bg); - transition: 0.5s; - - --ifm-link-color: white; - --ifm-link-hover-color: white; - - &:hover a { - opacity: 0.7; - } } \ No newline at end of file diff --git a/src/theme/DocPage/index.tsx b/src/theme/DocPage/index.tsx index 4974173ba1..1f7dea11a1 100644 --- a/src/theme/DocPage/index.tsx +++ b/src/theme/DocPage/index.tsx @@ -22,7 +22,6 @@ import {translate} from '@docusaurus/Translate'; import {ThemeClassNames} from '@docusaurus/theme-common'; import Head from '@docusaurus/Head'; import {useWindowSize} from '@docusaurus/theme-common'; -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import renderRoutes from '@docusaurus/renderRoutes'; import type {PropVersionMetadata} from '@docusaurus/plugin-content-docs-types'; @@ -178,22 +177,12 @@ function DocPage(props: Props): JSX.Element { />; } - const {siteConfig} = useDocusaurusContext(); - return ( <> - { - siteConfig.customFields?.FEATURE_DEV_FLAG === "true" &&
This is a staging build of the docs. Do not share the link externally.
- } - - { - siteConfig.customFields?.FEATURE_NEW_PRODUCTS_FLAG === "true" && - } - diff --git a/src/theme/DocPage/styles.module.scss b/src/theme/DocPage/styles.module.scss index 5456de3d4a..2a8a430d76 100644 --- a/src/theme/DocPage/styles.module.scss +++ b/src/theme/DocPage/styles.module.scss @@ -32,28 +32,7 @@ display: none; } -.devFlag { - width: 100%; - text-align: center; - padding: 4px 8px; - color: black; - background-color: orange; -} - -.newFlagPositive { - width: 100%; - text-align: center; - padding: 4px 8px; - background-color: var(--button-primary-bg); - transition: 0.5s; - --ifm-link-color: white; - --ifm-link-hover-color: white; - - &:hover a { - opacity: 0.7; - } -} @media (min-width: 997px) { .docMainContainer {