mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-11 05:06:36 +00:00
Merge pull request #303 from evangelostsak/feature/web-app-penpot
feat(web-app-penpot): add Penpot design & prototyping platform role
This commit is contained in:
22 files changed
+1090
No files matched your search
@@ -0,0 +1,104 @@
|
||||
# 023 - Setup Penpot
|
||||
|
||||
## User Story
|
||||
|
||||
As a Infinito.Nexus platform operator, I want to provide a fully integrated, production-ready Penpot design and prototyping platform within the Infinito.Nexus ecosystem so that teams can create, collaborate on, and share design projects directly inside their sovereign Infinito.Nexus installation.
|
||||
|
||||
## Context
|
||||
|
||||
[Penpot](https://penpot.app/) is an open-source design and prototyping platform (a self-hostable Figma alternative). This requirement tracks a new `web-app-penpot` Ansible role that deploys Penpot's containerized stack and wires it into the Infinito.Nexus ecosystem (identity, reverse proxy, marketplace, backup).
|
||||
|
||||
The role MUST follow the standard role-meta layout described in [layout.md](../contributing/design/role/services/layout.md) (`meta/services.yml`, `meta/schema.yml`, `meta/volumes.yml`, `meta/variants.yml`, `meta/info.yml`, `meta/main.yml`). [web-app-openproject](../../roles/web-app-openproject/) is the closest structural model — it combines PostgreSQL, a Redis `cache` service, background workers, an internal proxy, Keycloak SSO and LDAP — and SHOULD be used as the primary reference; [web-app-taiga](../../roles/web-app-taiga/) and [web-app-confluence](../../roles/web-app-confluence/) are secondary references.
|
||||
|
||||
Penpot's upstream stack is **frontend + backend + exporter + PostgreSQL + Redis**. The exporter container is mandatory: it backs the export (SVG/PDF) and developer-handoff features required below.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Role & meta layout
|
||||
|
||||
- [x] A new role exists at `roles/web-app-penpot/` and follows the role-meta layout in [layout.md](../contributing/design/role/services/layout.md).
|
||||
- [x] `meta/services.yml` declares every container service and a `lifecycle` key (starting tier `beta`).
|
||||
- [x] `meta/schema.yml` defines the role's configurable variables.
|
||||
- [x] `meta/volumes.yml` declares the role's named volumes.
|
||||
- [x] `meta/variants.yml` declares the matrix-deploy variants (see **Variants** below).
|
||||
- [x] `meta/info.yml` and `meta/main.yml` are present and valid.
|
||||
|
||||
### Compose stack
|
||||
|
||||
- [x] The stack includes a Penpot **frontend** container.
|
||||
- [x] The stack includes a Penpot **backend** container.
|
||||
- [x] The stack includes a Penpot **exporter** container (required for SVG/PDF export and developer handoff).
|
||||
- [x] PostgreSQL is provided via `svc-db-postgres` (declared through the `postgres` service block, not a role-local database container).
|
||||
- [x] Redis is provided via the `redis` service block in `meta/services.yml` (auto-emitted `svc-db-redis` sidecar; service name `redis`).
|
||||
- [x] All container volumes, env vars, ports and healthchecks follow Infinito.Nexus conventions.
|
||||
|
||||
### Identity integration
|
||||
|
||||
- [x] OIDC login is available via `web-app-keycloak`, enabled through `PENPOT_FLAGS` (e.g. `enable-login-with-oidc`). _(verified live against a Penpot + Keycloak deploy.)_
|
||||
- [x] LDAP login is available via `svc-db-openldap`, enabled through `PENPOT_FLAGS` (e.g. `enable-login-with-ldap`). _(verified live against a Penpot + OpenLDAP deploy.)_
|
||||
- [x] Native local email/password login is **off** under OIDC (`disable-login-with-password` forces users through SSO) and **on** otherwise, derived from the `sso` service flag (variant-aware); when on, the role bootstraps a local password for the `administrator` profile via the backend PREPL (`enable-prepl-server`) in `tasks/main.yml`, and the bootstrap is skipped under OIDC.
|
||||
- [x] Self-registration is **on** when OIDC is enabled (Penpot's OIDC JIT provisioning routes through the registration path) and **off** otherwise, derived from the `sso` service flag, rendering `enable-registration`/`disable-registration` in `PENPOT_FLAGS`.
|
||||
- [x] OIDC and LDAP configuration is fully automated via Ansible (env vars / templated config), requiring no manual post-deploy steps.
|
||||
|
||||
### CSP & reverse proxy
|
||||
|
||||
- [x] The role generates a correct Nginx/CSP vhost using [csp_filters.py](../../plugins/filter/csp_filters.py) and the `nginx_vhost` logic (via `sys-stk-full` + `meta/server.yml` CSP flags).
|
||||
- [x] The vhost configures WebSocket upgrade so real-time collaboration (live cursors, comments) works (handled by the shared proxy template).
|
||||
- [x] The vhost supports HTTPS termination and routes to internal service names.
|
||||
|
||||
### Variants
|
||||
|
||||
- [x] `meta/variants.yml` defines an **all-on** variant pinning every dynamic services flag to `true`.
|
||||
- [x] `meta/variants.yml` defines an **all-off** variant pinning every dynamic services flag to `false`.
|
||||
- [x] `meta/variants.yml` defines an **LDAP-only** variant (`ldap` on, `sso`/OIDC off) per the convention in [web-app-openproject/meta/variants.yml](../../roles/web-app-openproject/meta/variants.yml).
|
||||
|
||||
### Playwright coverage
|
||||
|
||||
- [x] A per-role Playwright spec exists and gates authenticated scenarios via `skipUnlessServiceEnabled` per [006 - Service-gated Playwright tests](README.md#archive).
|
||||
- [x] A **separate** OIDC login scenario and LDAP login scenario exist (not combined into one test body) per [018](018-playwright-ldap-coverage.md).
|
||||
- [x] Both the canonical admin persona and the non-admin RBAC persona `biber` are exercised per [017](017-playwright-biber-rbac-coverage.md).
|
||||
- [x] A native (local-password) admin login scenario exists (`test-login-native.js`), separate from the OIDC/LDAP companions, and is skipped when `sso` is enabled (native login disabled under OIDC).
|
||||
- [x] A scenario verifies project creation.
|
||||
- [x] A scenario verifies asset upload.
|
||||
|
||||
### Infinito.Nexus integration
|
||||
|
||||
- [x] A marketplace entry for "Penpot Design" is added and correctly categorized (auto-discovered `web-app` role with `meta/info.yml` + `galaxy_tags`).
|
||||
- [x] The role declares menu tags: `design`, `penpot`, `prototyping`, `collaboration`, `ui-ux`, `figma-alternative`.
|
||||
- [x] Desktop and mobile launcher URLs are generated automatically from the role's domain.
|
||||
- [x] PostgreSQL data is backed up via the existing backup role(s).
|
||||
- [x] The asset-storage volume is backed up via the existing backup role(s).
|
||||
|
||||
### Storage
|
||||
|
||||
- [x] A named volume is configured for asset storage (design files, images, fonts) and is backup-ready.
|
||||
- [x] Volume mounts follow Infinito.Nexus conventions.
|
||||
- [x] S3-compatible object storage is documented as a future option in the role README (not implemented).
|
||||
|
||||
### Definition of done
|
||||
|
||||
- [ ] Penpot deploys successfully with one command on a fresh host.
|
||||
- [ ] The app is reachable at its generated canonical domain (`penpot.design.{{ DOMAIN_PRIMARY }}`, e.g. `penpot.design.infinito.nexus`).
|
||||
- [x] `roles/web-app-penpot/README.md` documents the role's purpose, configuration, and the S3 future option.
|
||||
- [ ] `make test` passes with the new role in place. _(Penpot-relevant lint + integration suites verified green on host; full in-container `make test` requires `make setup`/sudo not available in the sandbox.)_
|
||||
|
||||
## Procedure
|
||||
|
||||
The implementation of this requirement MUST be executed autonomously by the agent. The following rules apply for the entire run and are non-negotiable:
|
||||
|
||||
- [x] **Clarifying questions only at the start.** Any open question, ambiguity, or missing decision (e.g. Penpot image versions, exporter resource limits, `PENPOT_FLAGS` set, lifecycle starting tier, secrets source) MUST be raised once at the very beginning of the run, in a single batched round, BEFORE any file is changed. Ambiguities discovered mid-run MUST be resolved by the agent using its best judgement, recorded in the role's `README.md` or a code comment, and revisited only at PR review.
|
||||
- [x] **Iteration loops.** The agent MUST follow the [Role Loop](../agents/action/iteration/role.md) for every change inside `roles/web-app-penpot/`, and the [Playwright Loop](../agents/action/iteration/playwright.md) for every change to the role's Playwright spec. The debug-locally step MUST NOT be skipped in favour of remote CI reruns.
|
||||
- [x] **No `ask` prompts mid-run.** The agent MUST NOT trigger any tool call that routes through `permissions.ask` in [.claude/settings.json](../../.claude/settings.json) during implementation. Where a tool would otherwise route through `ask`, the agent MUST select an equivalent covered by `permissions.allow`, or rephrase the operation to fit the sandbox. The single permitted exception is the final commit.
|
||||
- [x] **No interruptions.** Bug fixes, deploy failures, lint failures, `make test` failures, and healthcheck flaps MUST be resolved at their root inside this same iteration without prompting the operator. Workarounds, ad-hoc skips, retry-until-green loops, or "track in a follow-up" deferrals MUST NOT be used.
|
||||
- [ ] **One commit at the end.** ALL changes (role, meta, Playwright spec, documentation, marketplace entry, and the ticked checkboxes in this document) MUST be combined into ONE commit, created only after every Acceptance Criterion above is checked off (`- [x]`) and `make test` is green. The agent MUST NOT push; the operator runs `git-sign-push` outside the sandbox per [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
## See Also
|
||||
|
||||
- Upstream: [Penpot Official Website](https://penpot.app/)
|
||||
- Docker setup: [Penpot Docker Guide](https://github.com/penpot/penpot/tree/main/docker) (note the mandatory `exporter` service)
|
||||
- Requirements convention: [requirements.md](../contributing/requirements.md)
|
||||
- Role-meta layout: [layout.md](../contributing/design/role/services/layout.md)
|
||||
- Structural model: [web-app-openproject](../../roles/web-app-openproject/); secondary: [web-app-taiga](../../roles/web-app-taiga/), [web-app-confluence](../../roles/web-app-confluence/)
|
||||
- [006 - Service-gated Playwright tests](README.md#archive)
|
||||
- [017 - Playwright biber RBAC coverage](017-playwright-biber-rbac-coverage.md)
|
||||
- [018 - Playwright LDAP authentication coverage](018-playwright-ldap-coverage.md)
|
||||
@@ -27,4 +27,5 @@ Its scope is user stories and acceptance criteria that define what the project m
|
||||
- 019 - Playwright meta/services.yml parity coverage
|
||||
- 020 - CI Run 25705903504 Deploy-Failure Remediation Loop
|
||||
- 022 - Zammad Helpdesk Role with OIDC SSO
|
||||
- 023 - Setup Penpot
|
||||
- 025 - Matrix Role Ansible Flavor (matrix-docker-ansible-deploy)
|
||||
@@ -0,0 +1,143 @@
|
||||
# Penpot
|
||||
|
||||
## Description
|
||||
|
||||
Deploys [Penpot](https://penpot.app/), the open-source design and prototyping
|
||||
platform (a self-hostable Figma alternative) for UI/UX design, real-time
|
||||
collaboration, asset libraries, version history, and developer handoff — fully
|
||||
integrated into the Infinito.Nexus ecosystem.
|
||||
|
||||
## Overview
|
||||
|
||||
The role brings up Penpot's containerized stack behind the standard
|
||||
Infinito.Nexus reverse proxy and wires it into central identity, backup, and
|
||||
dashboard infrastructure. It follows the per-role meta layout
|
||||
([layout.md](../../docs/contributing/design/role/services/layout.md)) and is
|
||||
modeled on [`web-app-openproject`](../web-app-openproject/) and
|
||||
[`web-app-baserow`](../web-app-baserow/).
|
||||
|
||||
### Containers
|
||||
|
||||
| Service | Image | Purpose |
|
||||
|------------|------------------------|----------------------------------------------------------------|
|
||||
| `frontend` | `penpotapp/frontend` | Nginx + SPA; public HTTP surface, proxies to backend/exporter. |
|
||||
| `backend` | `penpotapp/backend` | API, auth (OIDC/LDAP), persistence, asset storage. |
|
||||
| `exporter` | `penpotapp/exporter` | Headless renderer for SVG/PDF/image export and dev handoff. |
|
||||
| `redis` | `svc-db-redis` (role-local sidecar, service name `redis`) | Cache / pub-sub for real-time collaboration. |
|
||||
| `postgres` | shared `svc-db-postgres` | Relational store. |
|
||||
|
||||
The **exporter** is mandatory: it backs the export (SVG/PDF) and developer
|
||||
handoff features.
|
||||
|
||||
## Features
|
||||
|
||||
- Design creation and editing, team collaboration with comments and live cursors.
|
||||
- Shared asset libraries and components, version history.
|
||||
- Export (SVG, PDF, images) and developer handoff (CSS/code) via the exporter.
|
||||
- Real-time collaboration over WebSockets through the proxy.
|
||||
|
||||
## Identity
|
||||
|
||||
Login methods are toggled through `PENPOT_FLAGS` and configured by Ansible:
|
||||
|
||||
- **OIDC** via [`web-app-keycloak`](../web-app-keycloak/) — enabled when the
|
||||
`sso` service is active (`flavor: oidc`, native in-app "OpenID" login). Adds
|
||||
`enable-login-with-oidc`.
|
||||
- **LDAP** via [`svc-db-openldap`](../svc-db-openldap/) — enabled when the
|
||||
`ldap` service is active. Adds `enable-login-with-ldap`.
|
||||
- **Native** local email/password login is *off* once OIDC is the login path
|
||||
(forces SSO), *on* otherwise — derived from the `sso` service flag. `PENPOT_FLAGS`
|
||||
renders `enable-login-with-password` / `disable-login-with-password` accordingly
|
||||
(no JS injection needed — Penpot disables the password form natively). When
|
||||
native login is on, the role bootstraps a local password for the `administrator`
|
||||
profile via the backend PREPL (`enable-prepl-server`) in `tasks/main.yml` —
|
||||
`create-profile` for a fresh profile, `update-profile` to set the password when
|
||||
a federated login already created it; the bootstrap is skipped under OIDC.
|
||||
|
||||
**Self-registration** is *on* when OIDC is enabled and *off* otherwise — also
|
||||
derived from the `sso` service flag. Penpot routes first-time OIDC logins through
|
||||
the registration path (a disabled flag makes the callback fail with
|
||||
`?error=registration-disabled` for not-yet-provisioned users), so registration
|
||||
must stay on wherever OIDC is. Native admin is bootstrapped and LDAP
|
||||
self-provisions without it. `PENPOT_FLAGS` renders `enable-registration` /
|
||||
`disable-registration` accordingly.
|
||||
|
||||
## Storage & scalability
|
||||
|
||||
- Assets are stored on the filesystem backend (`PENPOT_ASSETS_STORAGE_BACKEND=assets-fs`)
|
||||
on the backup-ready named volume `penpot_assets`, mounted into both `frontend`
|
||||
and `backend` at `/opt/data/assets`.
|
||||
- The PostgreSQL database is backed up by the central backup roles; the asset
|
||||
volume is backup-ready by convention.
|
||||
- **S3-compatible object storage (future):** Penpot supports an `assets-s3`
|
||||
backend (`PENPOT_STORAGE_ASSETS_FS_*` → `PENPOT_STORAGE_ASSETS_S3_*`). It is
|
||||
intentionally **not** implemented here; switching the backend and adding the
|
||||
S3 credentials to `meta/schema.yml` is the documented upgrade path.
|
||||
|
||||
## Ports & networking
|
||||
|
||||
- Canonical domain: `penpot.design.{{ DOMAIN_PRIMARY }}`.
|
||||
- Local proxy bind: `services.penpot.ports.local.http` (`8041`).
|
||||
- Role network: `192.168.105.192/28`.
|
||||
|
||||
## Autonomous-implementation notes
|
||||
|
||||
Resolved by best judgement during the autonomous build (per the requirement's
|
||||
Procedure); revisit at PR review:
|
||||
|
||||
- **Image tag** pinned to `2.5.4` for all three containers (single upstream tag).
|
||||
- **Exporter `PENPOT_PUBLIC_URI`** is shared from the single role env file (the
|
||||
external HTTPS base URL). Penpot upstream also supports an internal
|
||||
`http://frontend:8080` value; the shared-env contract renders one file for all
|
||||
containers, which the upstream image tolerates.
|
||||
- **CSP** allows `unsafe-inline`/`unsafe-eval` for `script-src-elem` and
|
||||
`blob:` workers/images, required by Penpot's SPA and worker-based renderer.
|
||||
- **OIDC JVM CA trust (self-signed TLS only):** when `TLS_MODE == self_signed`,
|
||||
the backend command (in `templates/compose.yml.j2`) imports the internal CA
|
||||
into a writable `cacerts` copy and points the JVM at it via `JAVA_TOOL_OPTIONS`.
|
||||
The shared `with-ca-trust.sh` entrypoint covers the OS/NSS/env trust stores but
|
||||
not the JVM truststore, so without this Penpot's server-side OIDC
|
||||
token/userinfo calls to Keycloak fail with `PKIX path building failed`. The
|
||||
override is gated behind self-signed mode (it is a no-op in production, where
|
||||
real CA-signed certs are already trusted) and the CA path is hardcoded because
|
||||
the framework's CA-override re-serialises the command.
|
||||
- **`disable-onboarding`** is in `PENPOT_FLAGS` so users land directly on the
|
||||
dashboard (sovereign install; also keeps the project/asset flows testable).
|
||||
|
||||
## Testing
|
||||
|
||||
The Playwright suite is split per login surface **and persona** (the runner
|
||||
globs every `*.js` under `files/playwright/`):
|
||||
|
||||
- `_shared.js` — env + login helpers (`penpotOidcLogin`, `penpotLdapLogin`,
|
||||
`penpotNativeLogin`, `penpotRegister`).
|
||||
- `test-login-native.js` — administrator native (local password) login;
|
||||
skipped when `sso` is enabled (native login is disabled under OIDC).
|
||||
- `test-login-oidc-admin.js` — OIDC via Keycloak, administrator.
|
||||
- `test-login-oidc-biber.js` — OIDC via Keycloak, `biber` (non-admin RBAC).
|
||||
- `test-login-ldap-admin.js` — OpenLDAP bind, administrator.
|
||||
- `test-login-ldap-biber.js` — OpenLDAP bind, `biber` (non-admin RBAC).
|
||||
- `playwright.spec.js` — orchestrator: TLS baseline, project creation, image
|
||||
asset upload, and the `guest`/`biber`/`administrator` personas. The `biber` /
|
||||
`administrator` personas are declared blocked via `PERSONA_*_BLOCKED`
|
||||
(mirroring `web-app-taiga`): Penpot's in-app "OpenID" entry and SPA user-menu
|
||||
logout aren't driveable by the generic persona helper, so both users' real
|
||||
auth is exercised by the dedicated login companions above.
|
||||
|
||||
Login/project/asset scenarios gate on the relevant service via
|
||||
`skipUnlessServiceEnabled`, so a deploy reports them `skipped` (not `failed`)
|
||||
when that service is disabled. Validated live against a Penpot + Keycloak +
|
||||
OpenLDAP deploy.
|
||||
|
||||
## Further Resources
|
||||
|
||||
- [Penpot Official Website](https://penpot.app/)
|
||||
- [Penpot Configuration Guide](https://help.penpot.app/technical-guide/configuration/)
|
||||
- [Penpot Docker Guide](https://github.com/penpot/penpot/tree/main/docker)
|
||||
|
||||
## Credits
|
||||
|
||||
Developed and maintained by **Kevin Veen-Birkenbach**.
|
||||
Learn more at [veen.world](https://www.veen.world).
|
||||
Part of the [Infinito.Nexus Project](https://s.infinito.nexus/code).
|
||||
Licensed under the [Infinito.Nexus Community License (Non-Commercial)](https://s.infinito.nexus/license).
|
||||
@@ -0,0 +1,124 @@
|
||||
// Shared state + login helpers for the Penpot Playwright suite.
|
||||
// playwright.spec.js wires the lifecycle hook and `require()`s one test-*.js
|
||||
// companion per login surface (native / oidc / ldap), passing this module as
|
||||
// `shared` so each scenario stays atomar and individually inspectable.
|
||||
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessServiceEnabled, isServiceEnabled } = require("./service-gating");
|
||||
const {
|
||||
decodeDotenvQuotedValue,
|
||||
normalizeBaseUrl,
|
||||
performKeycloakLoginForm,
|
||||
runAdminFlow,
|
||||
runBiberFlow,
|
||||
runGuestFlow,
|
||||
} = require("./personas");
|
||||
|
||||
const env = {
|
||||
baseUrl: normalizeBaseUrl(process.env.PENPOT_BASE_URL || ""),
|
||||
oidcIssuerUrl: normalizeBaseUrl(process.env.OIDC_ISSUER_URL || ""),
|
||||
canonicalDomain: decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN || ""),
|
||||
adminUsername: decodeDotenvQuotedValue(process.env.ADMIN_USERNAME),
|
||||
adminPassword: decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD),
|
||||
adminEmail: decodeDotenvQuotedValue(process.env.ADMIN_EMAIL),
|
||||
biberUsername: decodeDotenvQuotedValue(process.env.BIBER_USERNAME),
|
||||
biberPassword: decodeDotenvQuotedValue(process.env.BIBER_PASSWORD),
|
||||
biberEmail: decodeDotenvQuotedValue(process.env.BIBER_EMAIL),
|
||||
};
|
||||
|
||||
const loginRoute = (base) => `${base.replace(/\/$/, "")}/#/auth/login`;
|
||||
|
||||
async function assertAuthenticated(page) {
|
||||
// Leaving /auth/login proves a real session (dashboard/onboarding renders).
|
||||
await expect
|
||||
.poll(() => page.url(), { timeout: 60_000, message: "expected to leave the login route after sign-in" })
|
||||
.not.toContain("/auth/login");
|
||||
await expect(page.locator("body")).toBeVisible({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
// In-app OIDC (flavor: oidc): the login page renders an "OpenID" provider entry
|
||||
// (clickable text, not a role=button) that redirects to Keycloak.
|
||||
async function penpotOidcLogin(page, username, password) {
|
||||
const expectedAuth = `${env.oidcIssuerUrl}/protocol/openid-connect/auth`;
|
||||
await page.goto(loginRoute(env.baseUrl));
|
||||
const oidcEntry = page.getByText("OpenID", { exact: true });
|
||||
await expect(oidcEntry, "Expected a Penpot OpenID login entry").toBeVisible({ timeout: 60_000 });
|
||||
await oidcEntry.click();
|
||||
await expect
|
||||
.poll(() => page.url(), { timeout: 60_000, message: `expected redirect to ${expectedAuth}` })
|
||||
.toContain(expectedAuth);
|
||||
await performKeycloakLoginForm(page, username, password);
|
||||
await assertAuthenticated(page);
|
||||
}
|
||||
|
||||
// LDAP: a dedicated "LDAP" submit button (enabled once the form is filled)
|
||||
// binds against OpenLDAP directly — no Keycloak round-trip.
|
||||
async function penpotLdapLogin(page, email, password) {
|
||||
await page.goto(loginRoute(env.baseUrl));
|
||||
const emailField = page.getByLabel(/work email/i);
|
||||
const passwordField = page.getByLabel(/^password$/i);
|
||||
await expect(emailField, "Expected the Penpot login form").toBeVisible({ timeout: 60_000 });
|
||||
await emailField.fill(email);
|
||||
await passwordField.fill(password);
|
||||
const ldapButton = page.getByRole("button", { name: /^LDAP$/i });
|
||||
await expect(ldapButton, "Expected the LDAP submit button to enable once the form is filled").toBeEnabled({ timeout: 30_000 });
|
||||
await ldapButton.click();
|
||||
await assertAuthenticated(page);
|
||||
}
|
||||
|
||||
// Native: register a fresh local-DB account (Penpot's register flow can be a
|
||||
// two-step form: email/password, then full name). SMTP-off deployments activate
|
||||
// the profile immediately (no verification), landing on the dashboard.
|
||||
async function penpotRegister(page, fullname, email, password) {
|
||||
await page.goto(`${env.baseUrl.replace(/\/$/, "")}/#/auth/register`);
|
||||
const emailField = page.getByLabel(/work email|^email$/i).first();
|
||||
const passwordField = page.getByLabel(/^password$/i).first();
|
||||
await expect(emailField, "Expected the Penpot register form").toBeVisible({ timeout: 60_000 });
|
||||
await emailField.fill(email);
|
||||
await passwordField.fill(password);
|
||||
const next = page.getByRole("button", { name: /create an account|create account|sign up|register|next|continue/i }).first();
|
||||
await expect(next, "Expected the register submit button to enable").toBeEnabled({ timeout: 30_000 });
|
||||
await next.click();
|
||||
|
||||
// Optional second step: full name + terms acceptance.
|
||||
const nameField = page.getByLabel(/full ?name|^name$/i).first();
|
||||
if (await nameField.isVisible({ timeout: 15_000 }).catch(() => false)) {
|
||||
await nameField.fill(fullname);
|
||||
const terms = page.getByRole("checkbox").first();
|
||||
if (await terms.isVisible().catch(() => false)) await terms.check().catch(() => {});
|
||||
const finish = page.getByRole("button", { name: /create an account|create account|sign up|register|next|continue|finish/i }).first();
|
||||
await expect(finish).toBeEnabled({ timeout: 30_000 });
|
||||
await finish.click();
|
||||
}
|
||||
await assertAuthenticated(page);
|
||||
}
|
||||
|
||||
// Native: local-DB account via the email/password form + the "Login" button.
|
||||
async function penpotNativeLogin(page, email, password) {
|
||||
await page.goto(loginRoute(env.baseUrl));
|
||||
const emailField = page.getByLabel(/work email/i);
|
||||
const passwordField = page.getByLabel(/^password$/i);
|
||||
await expect(emailField, "Expected the Penpot login form").toBeVisible({ timeout: 60_000 });
|
||||
await emailField.fill(email);
|
||||
await passwordField.fill(password);
|
||||
const loginButton = page.getByRole("button", { name: /^Login$/i });
|
||||
await expect(loginButton, "Expected the Login button to enable once the form is filled").toBeEnabled({ timeout: 30_000 });
|
||||
await loginButton.click();
|
||||
await assertAuthenticated(page);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
test,
|
||||
expect,
|
||||
skipUnlessServiceEnabled,
|
||||
isServiceEnabled,
|
||||
runAdminFlow,
|
||||
runBiberFlow,
|
||||
runGuestFlow,
|
||||
env,
|
||||
loginRoute,
|
||||
penpotOidcLogin,
|
||||
penpotLdapLogin,
|
||||
penpotNativeLogin,
|
||||
penpotRegister,
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
// Penpot Playwright spec — orchestration only. Shared state, login helpers and
|
||||
// the lifecycle hook live in `_shared.js`; each login surface is registered
|
||||
// from its own `test-login-*.js` companion so every scenario stays atomar and
|
||||
// individually inspectable.
|
||||
|
||||
const shared = require("./_shared");
|
||||
const { test, expect, skipUnlessServiceEnabled, runGuestFlow, runBiberFlow, runAdminFlow, env, penpotOidcLogin } = shared;
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
expect(env.baseUrl, "PENPOT_BASE_URL must be set").toBeTruthy();
|
||||
expect(env.canonicalDomain, "CANONICAL_DOMAIN must be set").toBeTruthy();
|
||||
await page.context().clearCookies();
|
||||
});
|
||||
|
||||
test("baseline: Penpot responds on the canonical domain with TLS", async ({ page }) => {
|
||||
const r = await page.goto(`${env.baseUrl}/`);
|
||||
expect(r, "Expected Penpot response").toBeTruthy();
|
||||
expect(r.status(), "Expected Penpot front page status < 500").toBeLessThan(500);
|
||||
expect(
|
||||
r.url().includes(env.canonicalDomain),
|
||||
`Expected canonical domain "${env.canonicalDomain}" to back the Penpot URL`,
|
||||
).toBe(true);
|
||||
expect(r.headers()["strict-transport-security"], "Penpot must emit HSTS").toBeTruthy();
|
||||
});
|
||||
|
||||
// Login surfaces — one companion per method × persona.
|
||||
require("./test-login-native").register(shared);
|
||||
require("./test-login-oidc-admin").register(shared);
|
||||
require("./test-login-oidc-biber").register(shared);
|
||||
require("./test-login-ldap-admin").register(shared);
|
||||
require("./test-login-ldap-biber").register(shared);
|
||||
|
||||
test("project: administrator creates a design project", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("sso");
|
||||
test.setTimeout(120_000); // OIDC round-trip + dashboard project creation
|
||||
await penpotOidcLogin(page, env.adminUsername, env.adminPassword);
|
||||
const projectName = `pw-project-${Date.now()}`;
|
||||
const addProject = page
|
||||
.getByRole("button", { name: /new project|add project|create.*project/i })
|
||||
.or(page.locator('[data-testid="add-project"], [data-test="add-project"]'))
|
||||
.first();
|
||||
await expect(addProject, "Expected a create-project control on the dashboard").toBeVisible({ timeout: 60_000 });
|
||||
await addProject.click();
|
||||
const nameInput = page.locator('.project-name input, input[type="text"]:visible, [contenteditable="true"]:visible').first();
|
||||
await nameInput.fill(projectName);
|
||||
await nameInput.press("Enter");
|
||||
await expect(page.getByText(projectName, { exact: false }).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("asset: administrator uploads an image asset into a design file", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("sso");
|
||||
test.setTimeout(180_000); // OIDC + editor load + image upload
|
||||
await penpotOidcLogin(page, env.adminUsername, env.adminPassword);
|
||||
|
||||
// Open Drafts and create a new file; Penpot navigates into the workspace editor.
|
||||
await page.getByText("Drafts", { exact: true }).first().click();
|
||||
const newFile = page.getByText(/\+\s*New File/i).first();
|
||||
await expect(newFile, "Expected a create-file control in Drafts").toBeVisible({ timeout: 60_000 });
|
||||
await newFile.click();
|
||||
await expect.poll(() => page.url(), { timeout: 90_000, message: "expected to enter the Penpot workspace editor" })
|
||||
.toContain("/workspace");
|
||||
|
||||
// Upload a small PNG into the file via the workspace image file input.
|
||||
const onePixelPng = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
const fileInput = page.locator('input[type="file"]').first();
|
||||
await fileInput.waitFor({ state: "attached", timeout: 60_000 });
|
||||
await fileInput.setInputFiles({ name: "pw-asset.png", mimeType: "image/png", buffer: onePixelPng });
|
||||
|
||||
// The uploaded image becomes a board/shape on the canvas; assert Penpot
|
||||
// acknowledges the upload (a layer/element referencing the file appears).
|
||||
await expect(page.getByText(/pw-asset|image/i).first()).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
|
||||
// Persona scenarios. Bodies live in the shared persona helpers. Penpot's login
|
||||
// is an in-app "OpenID" provider entry (clickable text, not a `login`/`sign-in`
|
||||
// link) and its logout sits behind an SPA user menu the generic persona helper
|
||||
// does not recognise, so the authenticated biber / administrator persona
|
||||
// journeys are declared blocked (PERSONA_*_BLOCKED, mirrors web-app-taiga).
|
||||
// Their real auth paths are exercised by the dedicated login companions above.
|
||||
|
||||
test("guest: public-landing → auth chain → never authenticated", async ({ page }) => {
|
||||
await runGuestFlow(page);
|
||||
});
|
||||
|
||||
test("biber: app → role interaction → universal logout", async ({ page }) => {
|
||||
await runBiberFlow(page);
|
||||
});
|
||||
|
||||
test("administrator: app → admin interaction → universal logout", async ({ page }) => {
|
||||
await runAdminFlow(page);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
// LDAP-bind login against OpenLDAP — administrator persona. Gated on the
|
||||
// `ldap` service.
|
||||
exports.register = (shared) => {
|
||||
const { test, expect, skipUnlessServiceEnabled, env, penpotLdapLogin } = shared;
|
||||
|
||||
test("LDAP: administrator binds against OpenLDAP", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("ldap");
|
||||
test.setTimeout(90_000); // LDAP bind + first authenticated render
|
||||
expect(env.adminEmail).toBeTruthy();
|
||||
expect(env.adminPassword).toBeTruthy();
|
||||
await penpotLdapLogin(page, env.adminEmail, env.adminPassword);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
// LDAP-bind login against OpenLDAP — non-admin RBAC user `biber`. Gated on the
|
||||
// `ldap` service.
|
||||
exports.register = (shared) => {
|
||||
const { test, expect, skipUnlessServiceEnabled, env, penpotLdapLogin } = shared;
|
||||
|
||||
test("LDAP: biber non-admin RBAC user binds against OpenLDAP", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("ldap");
|
||||
test.setTimeout(90_000); // LDAP bind + first authenticated render
|
||||
expect(env.biberEmail).toBeTruthy();
|
||||
expect(env.biberPassword).toBeTruthy();
|
||||
await penpotLdapLogin(page, env.biberEmail, env.biberPassword);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
// Native (local-DB) email/password login for the administrator. Disabled under
|
||||
// OIDC (disable-login-with-password), so skip when `sso` is on.
|
||||
exports.register = (shared) => {
|
||||
const { test, expect, isServiceEnabled, env, penpotNativeLogin } = shared;
|
||||
|
||||
test("native: administrator local password login", async ({ page }) => {
|
||||
test.skip(isServiceEnabled("sso"), "native password login is disabled when OIDC is enabled");
|
||||
test.setTimeout(90_000);
|
||||
expect(env.adminEmail, "ADMIN_EMAIL must be set").toBeTruthy();
|
||||
expect(env.adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
|
||||
await penpotNativeLogin(page, env.adminEmail, env.adminPassword);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
// OIDC login via Keycloak — administrator persona. Gated on the `sso` service.
|
||||
exports.register = (shared) => {
|
||||
const { test, expect, skipUnlessServiceEnabled, env, penpotOidcLogin } = shared;
|
||||
|
||||
test("OIDC: administrator signs in via Keycloak", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("sso");
|
||||
test.setTimeout(120_000); // OIDC round-trip + Keycloak login form
|
||||
expect(env.adminUsername).toBeTruthy();
|
||||
expect(env.adminPassword).toBeTruthy();
|
||||
expect(env.oidcIssuerUrl).toBeTruthy();
|
||||
await penpotOidcLogin(page, env.adminUsername, env.adminPassword);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// OIDC login via Keycloak — non-admin RBAC user `biber`. Gated on the `sso`
|
||||
// service. First-time biber sign-in is JIT-provisioned by Penpot, which needs
|
||||
// `enable-registration` (default on when OIDC is enabled).
|
||||
exports.register = (shared) => {
|
||||
const { test, expect, skipUnlessServiceEnabled, env, penpotOidcLogin } = shared;
|
||||
|
||||
test("OIDC: biber non-admin RBAC user signs in via Keycloak", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("sso");
|
||||
test.setTimeout(120_000); // OIDC round-trip + Keycloak login form
|
||||
expect(env.biberUsername).toBeTruthy();
|
||||
expect(env.biberPassword).toBeTruthy();
|
||||
expect(env.oidcIssuerUrl).toBeTruthy();
|
||||
await penpotOidcLogin(page, env.biberUsername, env.biberPassword);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
logo:
|
||||
class: fa-solid fa-pen-ruler
|
||||
homepage: https://penpot.app/
|
||||
video: https://www.youtube.com/watch?v=vAkb4QQmbfk
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
galaxy_info:
|
||||
author: Evangelos Tsakoudis
|
||||
description: Penpot is the open-source design and prototyping platform for UI/UX design, real-time collaboration, and developer handoff.
|
||||
license: Infinito.Nexus Community License (Non-Commercial)
|
||||
company: |
|
||||
Kevin Veen-Birkenbach
|
||||
https://www.veen.world
|
||||
min_ansible_version: "2.9"
|
||||
platforms:
|
||||
- name: ArchLinux
|
||||
versions:
|
||||
- all
|
||||
- name: Debian
|
||||
versions:
|
||||
- all
|
||||
- name: EL
|
||||
versions:
|
||||
- all
|
||||
- name: Fedora
|
||||
versions:
|
||||
- all
|
||||
- name: Ubuntu
|
||||
versions:
|
||||
- all
|
||||
galaxy_tags:
|
||||
- design
|
||||
- penpot
|
||||
- prototyping
|
||||
- collaboration
|
||||
- ui-ux
|
||||
- figma-alternative
|
||||
- docker
|
||||
- compose
|
||||
- ldap
|
||||
- sso
|
||||
issue_tracker_url: https://s.infinito.nexus/issues
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
credentials:
|
||||
secret_key:
|
||||
description: Main secret used by Penpot to sign tokens, cookies and session data.
|
||||
algorithm: sha256
|
||||
validation: ^[a-f0-9]{64}$
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
status_codes:
|
||||
default:
|
||||
- 200
|
||||
- 301
|
||||
- 302
|
||||
domains:
|
||||
canonical:
|
||||
- penpot.design.{{ DOMAIN_PRIMARY }}
|
||||
aliases: []
|
||||
csp:
|
||||
whitelist:
|
||||
worker-src:
|
||||
- "blob:"
|
||||
img-src:
|
||||
- "data:"
|
||||
- "blob:"
|
||||
font-src:
|
||||
- "data:"
|
||||
flags:
|
||||
script-src-elem:
|
||||
unsafe-inline: true
|
||||
unsafe-eval: true
|
||||
script-src-attr:
|
||||
unsafe-eval: true
|
||||
style-src-attr:
|
||||
unsafe-inline: true
|
||||
networks:
|
||||
local:
|
||||
subnet: 192.168.105.192/28
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
logout:
|
||||
enabled: "{{ 'web-svc-logout' in group_names }}"
|
||||
shared: "{{ 'web-svc-logout' in group_names }}"
|
||||
dashboard:
|
||||
enabled: "{{ 'web-app-dashboard' in group_names }}"
|
||||
shared: "{{ 'web-app-dashboard' in group_names }}"
|
||||
matomo:
|
||||
enabled: "{{ 'web-app-matomo' in group_names }}"
|
||||
shared: "{{ 'web-app-matomo' in group_names }}"
|
||||
email:
|
||||
enabled: "{{ 'web-app-mailu' in group_names }}"
|
||||
shared: "{{ 'web-app-mailu' in group_names }}"
|
||||
sso:
|
||||
enabled: "{{ 'web-app-keycloak' in group_names }}"
|
||||
shared: "{{ 'web-app-keycloak' in group_names }}"
|
||||
flavor: oidc
|
||||
ldap:
|
||||
enabled: "{{ 'svc-db-openldap' in group_names }}"
|
||||
shared: "{{ 'svc-db-openldap' in group_names }}"
|
||||
# nocheck: shared
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — DB engine, covered by role-local integration tests
|
||||
redis:
|
||||
backup:
|
||||
disabled: true
|
||||
enabled: true
|
||||
# nocheck: playwright-service-flag — DB engine, covered by role-local integration tests
|
||||
postgres:
|
||||
enabled: true # nocheck: dynamic-flag
|
||||
shared: "{{ 'svc-db-postgres' in group_names }}"
|
||||
penpot:
|
||||
version: "2.5.4"
|
||||
min_storage: 10GB
|
||||
cpus: "0.5"
|
||||
mem_reservation: 512m
|
||||
mem_limit: 1g
|
||||
pids_limit: 512
|
||||
ports:
|
||||
local:
|
||||
http: 8041
|
||||
sso: 16506
|
||||
run_after:
|
||||
- web-app-matomo
|
||||
- web-app-keycloak
|
||||
- web-app-mailu
|
||||
lifecycle: beta
|
||||
frontend:
|
||||
image: penpotapp/frontend
|
||||
name: penpot-frontend
|
||||
cpus: "0.5"
|
||||
mem_reservation: 256m
|
||||
mem_limit: 512m
|
||||
pids_limit: 256
|
||||
backend:
|
||||
image: penpotapp/backend
|
||||
name: penpot-backend
|
||||
cpus: "1.0"
|
||||
mem_reservation: 1g
|
||||
mem_limit: 2g
|
||||
pids_limit: 512
|
||||
exporter:
|
||||
image: penpotapp/exporter
|
||||
name: penpot-exporter
|
||||
cpus: "0.5"
|
||||
mem_reservation: 512m
|
||||
mem_limit: 1g
|
||||
pids_limit: 512
|
||||
# nocheck: playwright-service-flag — css consumer, no Playwright surface (provider: web-svc-css)
|
||||
css:
|
||||
enabled: "{{ 'web-svc-css' in group_names }}"
|
||||
shared: "{{ 'web-svc-css' in group_names }}"
|
||||
# nocheck: dynamic-flag
|
||||
# nocheck: playwright-service-flag — canonical:cdn alias of static-asset provider
|
||||
javascript:
|
||||
enabled: false
|
||||
shared: false
|
||||
prometheus:
|
||||
enabled: "{{ 'web-app-prometheus' in group_names }}"
|
||||
shared: "{{ 'web-app-prometheus' in group_names }}"
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
# Matrix-deploy variant overrides for web-app-penpot.
|
||||
# See docs/contributing/artefact/files/role/variants.md for the file format.
|
||||
|
||||
# Variant pinning every dynamic services.yml flag to true so the matrix
|
||||
# exercises the in-group_names branch of web-app-penpot.
|
||||
- services:
|
||||
css:
|
||||
enabled: true
|
||||
shared: true
|
||||
dashboard:
|
||||
enabled: true
|
||||
shared: true
|
||||
email:
|
||||
enabled: true
|
||||
shared: true
|
||||
ldap:
|
||||
enabled: true
|
||||
shared: true
|
||||
logout:
|
||||
enabled: true
|
||||
shared: true
|
||||
matomo:
|
||||
enabled: true
|
||||
shared: true
|
||||
sso:
|
||||
enabled: true
|
||||
shared: true
|
||||
postgres:
|
||||
shared: true
|
||||
prometheus:
|
||||
enabled: true
|
||||
shared: true
|
||||
# Variant pinning every dynamic services.yml flag to false.
|
||||
- services:
|
||||
css:
|
||||
enabled: false
|
||||
shared: false
|
||||
dashboard:
|
||||
enabled: false
|
||||
shared: false
|
||||
email:
|
||||
enabled: false
|
||||
shared: false
|
||||
ldap:
|
||||
enabled: false
|
||||
shared: false
|
||||
logout:
|
||||
enabled: false
|
||||
shared: false
|
||||
matomo:
|
||||
enabled: false
|
||||
shared: false
|
||||
sso:
|
||||
enabled: false
|
||||
shared: false
|
||||
postgres:
|
||||
shared: false
|
||||
prometheus:
|
||||
enabled: false
|
||||
shared: false
|
||||
# LDAP-only variant: LDAP bind login on, OIDC/SSO off, so the matrix
|
||||
# exercises the LDAP authentication branch in isolation.
|
||||
- services:
|
||||
ldap:
|
||||
enabled: true
|
||||
shared: true
|
||||
sso:
|
||||
enabled: false
|
||||
shared: false
|
||||
css:
|
||||
enabled: false
|
||||
shared: false
|
||||
dashboard:
|
||||
enabled: false
|
||||
shared: false
|
||||
email:
|
||||
enabled: false
|
||||
shared: false
|
||||
logout:
|
||||
enabled: false
|
||||
shared: false
|
||||
matomo:
|
||||
enabled: false
|
||||
shared: false
|
||||
prometheus:
|
||||
enabled: false
|
||||
shared: false
|
||||
@@ -0,0 +1,2 @@
|
||||
---
|
||||
assets: penpot_assets
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
- name: "For '{{ application_id }}': load docker, db and proxy"
|
||||
include_role:
|
||||
name: sys-stk-full
|
||||
vars:
|
||||
# Penpot uploads (design files, images, fonts) can be large; raise the
|
||||
# proxy body limit so asset imports are not truncated at the front door.
|
||||
webserver_client_max_body_size: "100m"
|
||||
compose_handlers_flush: true
|
||||
|
||||
# Bootstrap the administrator's local password via the backend PREPL when native
|
||||
# login is on (skipped under OIDC). update-profile sets it if the profile exists.
|
||||
- name: "Bootstrap the Penpot administrator's local account (idempotent)"
|
||||
when: PENPOT_NATIVE_LOGIN_ENABLED | bool
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- compose
|
||||
- exec
|
||||
- -T
|
||||
- "{{ PENPOT_BACKEND_SERVICE }}"
|
||||
- python3
|
||||
- manage.py
|
||||
- create-profile
|
||||
- -n
|
||||
- "{{ PENPOT_ADMIN_FULLNAME }}"
|
||||
- -e
|
||||
- "{{ PENPOT_ADMIN_EMAIL }}"
|
||||
- -p
|
||||
- "{{ PENPOT_ADMIN_PASSWORD }}"
|
||||
chdir: "{{ lookup('container', application_id, 'directories.instance') }}"
|
||||
register: penpot_admin_create
|
||||
retries: 30
|
||||
delay: 5
|
||||
until: >-
|
||||
penpot_admin_create.rc == 0 or
|
||||
('already exists' in (penpot_admin_create.stdout | default('') ~ penpot_admin_create.stderr | default('')))
|
||||
changed_when: "'Created' in penpot_admin_create.stdout"
|
||||
failed_when:
|
||||
- penpot_admin_create.rc != 0
|
||||
- "'already exists' not in (penpot_admin_create.stdout | default('') ~ penpot_admin_create.stderr | default(''))"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: "Set the Penpot administrator's local password when the profile already exists"
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- compose
|
||||
- exec
|
||||
- -T
|
||||
- "{{ PENPOT_BACKEND_SERVICE }}"
|
||||
- python3
|
||||
- manage.py
|
||||
- update-profile
|
||||
- -e
|
||||
- "{{ PENPOT_ADMIN_EMAIL }}"
|
||||
- -p
|
||||
- "{{ PENPOT_ADMIN_PASSWORD }}"
|
||||
chdir: "{{ lookup('container', application_id, 'directories.instance') }}"
|
||||
when:
|
||||
- PENPOT_NATIVE_LOGIN_ENABLED | bool
|
||||
- "'already exists' in (penpot_admin_create.stdout | default('') ~ penpot_admin_create.stderr | default(''))"
|
||||
register: penpot_admin_update
|
||||
changed_when: "'Updated' in penpot_admin_update.stdout"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
@@ -0,0 +1,57 @@
|
||||
{# Penpot stack: frontend + backend + exporter, with shared postgres and a #}
|
||||
{# role-local redis sidecar (both emitted by the sys-svc-compose base). #}
|
||||
{% include 'roles/sys-svc-compose/templates/base.yml.j2' %}
|
||||
|
||||
{{ PENPOT_FRONTEND_SERVICE }}:
|
||||
{% set service_name = PENPOT_FRONTEND_SERVICE %}
|
||||
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
|
||||
image: "{{ PENPOT_FRONTEND_IMAGE }}:{{ PENPOT_VERSION }}"
|
||||
container_name: {{ PENPOT_FRONTEND_CONTAINER }}
|
||||
ports:
|
||||
- "{{ DOCKER_BIND_HOST }}:{{ lookup('config', application_id, 'services.penpot.ports.local.http') }}:{{ PENPOT_FRONTEND_INTERNAL_PORT }}"
|
||||
volumes:
|
||||
- "assets:{{ PENPOT_ASSETS_FS_DIRECTORY }}"
|
||||
{% set container_port = PENPOT_FRONTEND_INTERNAL_PORT %}
|
||||
{% include 'roles/sys-svc-container/templates/healthcheck/curl.yml.j2' %}
|
||||
{% include 'roles/sys-svc-container/templates/networks.yml.j2' %}
|
||||
{{ lookup('container_depends_on', application_id, extra={
|
||||
PENPOT_BACKEND_SERVICE: {'condition': 'service_started'},
|
||||
PENPOT_EXPORTER_SERVICE: {'condition': 'service_started'},
|
||||
}) }}
|
||||
|
||||
{{ PENPOT_BACKEND_SERVICE }}:
|
||||
{% set service_name = PENPOT_BACKEND_SERVICE %}
|
||||
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
|
||||
image: "{{ PENPOT_BACKEND_IMAGE }}:{{ PENPOT_VERSION }}"
|
||||
container_name: {{ PENPOT_BACKEND_CONTAINER }}
|
||||
{% if (TLS_MODE | lower) == 'self_signed' %}
|
||||
# Self-signed TLS only: the JVM truststore doesn't trust the internal CA
|
||||
# (with-ca-trust.sh covers OS/NSS/env, not Java cacerts; backend runs uid 1001),
|
||||
# so import it before start; otherwise OIDC calls to Keycloak fail PKIX.
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- |
|
||||
cp /opt/jdk/lib/security/cacerts /tmp/cacerts 2>/dev/null || true
|
||||
/opt/jdk/bin/keytool -importcert -keystore /tmp/cacerts -storepass changeit -noprompt -alias infinito-ca -file /tmp/infinito/ca/root-ca.crt >/dev/null 2>&1 || true
|
||||
export JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=/tmp/cacerts -Djavax.net.ssl.trustStorePassword=changeit"
|
||||
exec /bin/bash run.sh
|
||||
{% endif %}
|
||||
volumes:
|
||||
- "assets:{{ PENPOT_ASSETS_FS_DIRECTORY }}"
|
||||
{% include 'roles/sys-svc-container/templates/networks.yml.j2' %}
|
||||
{{ lookup('container_depends_on', application_id) }}
|
||||
|
||||
{{ PENPOT_EXPORTER_SERVICE }}:
|
||||
{% set service_name = PENPOT_EXPORTER_SERVICE %}
|
||||
{% include 'roles/sys-svc-container/templates/base.yml.j2' %}
|
||||
image: "{{ PENPOT_EXPORTER_IMAGE }}:{{ PENPOT_VERSION }}"
|
||||
container_name: {{ PENPOT_EXPORTER_CONTAINER }}
|
||||
{% include 'roles/sys-svc-container/templates/networks.yml.j2' %}
|
||||
{{ lookup('container_depends_on', application_id) }}
|
||||
|
||||
{{ lookup('applications') | compose_volumes(
|
||||
application_id,
|
||||
extra_volumes={'assets': {'name': PENPOT_ASSETS_VOLUME}}
|
||||
) }}
|
||||
{% include 'roles/sys-svc-compose/templates/networks.yml.j2' %}
|
||||
@@ -0,0 +1,72 @@
|
||||
# Penpot configuration — shared by the frontend, backend and exporter
|
||||
# containers. See https://help.penpot.app/technical-guide/configuration/
|
||||
# Unknown keys are ignored per container, so a single env file is safe.
|
||||
|
||||
# Feature flags (registration, password login, IdP toggles, telemetry)
|
||||
PENPOT_FLAGS={{ PENPOT_FLAGS | trim }}
|
||||
|
||||
# Public entrypoint
|
||||
PENPOT_PUBLIC_URI={{ PENPOT_PUBLIC_URI }}
|
||||
|
||||
# Secret
|
||||
PENPOT_SECRET_KEY={{ PENPOT_SECRET_KEY | dotenv_quote }}
|
||||
|
||||
# Telemetry off (sovereign install)
|
||||
PENPOT_TELEMETRY_ENABLED=false
|
||||
|
||||
# Internal service wiring (frontend ↔ backend ↔ exporter)
|
||||
PENPOT_BACKEND_URI={{ PENPOT_BACKEND_URI }}
|
||||
PENPOT_EXPORTER_URI={{ PENPOT_EXPORTER_URI }}
|
||||
|
||||
# Database (shared svc-db-postgres)
|
||||
PENPOT_DATABASE_URI={{ PENPOT_DATABASE_URI }}
|
||||
PENPOT_DATABASE_USERNAME={{ PENPOT_DATABASE_USERNAME }}
|
||||
PENPOT_DATABASE_PASSWORD={{ PENPOT_DATABASE_PASSWORD | dotenv_quote }}
|
||||
|
||||
# Redis (role-local sidecar)
|
||||
PENPOT_REDIS_URI={{ PENPOT_REDIS_URI }}
|
||||
|
||||
# Asset storage (filesystem backend on a backup-ready named volume)
|
||||
PENPOT_ASSETS_STORAGE_BACKEND=assets-fs
|
||||
PENPOT_STORAGE_ASSETS_FS_DIRECTORY={{ PENPOT_ASSETS_FS_DIRECTORY }}
|
||||
|
||||
{% set email = lookup('email', application_id) %}
|
||||
{% if email.enabled | bool %}
|
||||
# Email (SMTP via Mailu)
|
||||
PENPOT_SMTP_ENABLED={{ email.enabled | string | lower }}
|
||||
PENPOT_SMTP_DEFAULT_FROM={{ email.from }}
|
||||
PENPOT_SMTP_DEFAULT_REPLY_TO={{ email.from }}
|
||||
PENPOT_SMTP_HOST={{ email.host }}
|
||||
PENPOT_SMTP_PORT={{ email.port }}
|
||||
PENPOT_SMTP_USERNAME={{ email.username }}
|
||||
PENPOT_SMTP_PASSWORD={{ email.password | dotenv_quote }}
|
||||
PENPOT_SMTP_TLS={{ email.tls | lower }}
|
||||
PENPOT_SMTP_SSL={{ email.ssl | default(false) | lower }}
|
||||
{% endif %}
|
||||
|
||||
{% if PENPOT_SSO_ENABLED | bool %}
|
||||
# OIDC (Keycloak)
|
||||
PENPOT_OIDC_CLIENT_ID={{ PENPOT_OIDC_CLIENT_ID }}
|
||||
PENPOT_OIDC_CLIENT_SECRET={{ PENPOT_OIDC_CLIENT_SECRET | dotenv_quote }}
|
||||
PENPOT_OIDC_BASE_URI={{ PENPOT_OIDC_BASE_URI }}
|
||||
PENPOT_OIDC_AUTH_URI={{ PENPOT_OIDC_AUTH_URI }}
|
||||
PENPOT_OIDC_TOKEN_URI={{ PENPOT_OIDC_TOKEN_URI }}
|
||||
PENPOT_OIDC_USER_URI={{ PENPOT_OIDC_USER_URI }}
|
||||
PENPOT_OIDC_SCOPES={{ PENPOT_OIDC_SCOPES }}
|
||||
PENPOT_OIDC_EMAIL_ATTR=email
|
||||
PENPOT_OIDC_NAME_ATTR=name
|
||||
{% endif %}
|
||||
|
||||
{% if PENPOT_LDAP_ENABLED | bool %}
|
||||
# LDAP (OpenLDAP)
|
||||
PENPOT_LDAP_HOST={{ PENPOT_LDAP_HOST }}
|
||||
PENPOT_LDAP_PORT={{ PENPOT_LDAP_PORT }}
|
||||
PENPOT_LDAP_SSL={{ ((LDAP.SERVER.SECURITY | upper) == 'SSL') | string | lower }}
|
||||
PENPOT_LDAP_STARTTLS={{ ((LDAP.SERVER.SECURITY | upper) == 'TLS') | string | lower }}
|
||||
PENPOT_LDAP_BASE_DN={{ PENPOT_LDAP_BASE_DN }}
|
||||
PENPOT_LDAP_BIND_DN={{ PENPOT_LDAP_BIND_DN }}
|
||||
PENPOT_LDAP_BIND_PASSWORD={{ PENPOT_LDAP_BIND_PASSWORD | dotenv_quote }}
|
||||
PENPOT_LDAP_ATTRS_USERNAME={{ PENPOT_LDAP_ATTRS_USERNAME }}
|
||||
PENPOT_LDAP_ATTRS_EMAIL={{ PENPOT_LDAP_ATTRS_EMAIL }}
|
||||
PENPOT_LDAP_ATTRS_FULLNAME={{ PENPOT_LDAP_ATTRS_FULLNAME }}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,21 @@
|
||||
ADMIN_EMAIL={{ lookup('users', 'administrator').email | dotenv_quote }}
|
||||
ADMIN_PASSWORD={{ lookup('users', 'administrator').password | dotenv_quote }}
|
||||
ADMIN_USERNAME={{ lookup('users', 'administrator').username | dotenv_quote }}
|
||||
APP_BASE_URL={{ lookup('tls', application_id, 'url.base') | dotenv_quote }}
|
||||
BIBER_EMAIL={{ lookup('users', 'biber').email | dotenv_quote }}
|
||||
BIBER_PASSWORD={{ lookup('users', 'biber').password | dotenv_quote }}
|
||||
BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }}
|
||||
CANONICAL_DOMAIN={{ lookup('domain', application_id) | dotenv_quote }}
|
||||
# nocheck: playwright-service-gate — covered by sys-svc-mail integration tests, not the persona surface
|
||||
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
|
||||
LDAP_SERVICE_ENABLED={{ PENPOT_LDAP_ENABLED | string | lower }}
|
||||
LOGOUT_SERVICE_ENABLED={{ lookup('config', application_id, 'services.logout.enabled') | string | lower }}
|
||||
MATOMO_BASE_URL={{ lookup('tls', 'web-app-matomo', 'url.base') | dotenv_quote }}
|
||||
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }}
|
||||
PENPOT_BASE_URL={{ lookup('tls', application_id, 'url.base') | dotenv_quote }}
|
||||
# Generic helper can't drive Penpot's in-app OpenID login / SPA logout (README); biber+admin auth covered by the OIDC/LDAP scenarios.
|
||||
PERSONA_ADMINISTRATOR_BLOCKED=true
|
||||
PERSONA_BIBER_BLOCKED=true
|
||||
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
# General
|
||||
application_id: "web-app-penpot"
|
||||
entity_name: "{{ application_id | get_entity_name }}"
|
||||
|
||||
# Penpot images / versions (all three containers share the upstream tag)
|
||||
PENPOT_VERSION: "{{ lookup('config', application_id, 'services.penpot.version') }}"
|
||||
PENPOT_FRONTEND_IMAGE: "{{ lookup('config', application_id, 'services.frontend.image') }}"
|
||||
PENPOT_BACKEND_IMAGE: "{{ lookup('config', application_id, 'services.backend.image') }}"
|
||||
PENPOT_EXPORTER_IMAGE: "{{ lookup('config', application_id, 'services.exporter.image') }}"
|
||||
PENPOT_FRONTEND_CONTAINER: "{{ lookup('config', application_id, 'services.frontend.name') }}"
|
||||
PENPOT_BACKEND_CONTAINER: "{{ lookup('config', application_id, 'services.backend.name') }}"
|
||||
PENPOT_EXPORTER_CONTAINER: "{{ lookup('config', application_id, 'services.exporter.name') }}"
|
||||
PENPOT_ASSETS_VOLUME: "{{ lookup('config', application_id, 'volumes.assets') }}"
|
||||
|
||||
# Compose-internal service names (must match the keys emitted in compose.yml.j2)
|
||||
PENPOT_FRONTEND_SERVICE: "frontend"
|
||||
PENPOT_BACKEND_SERVICE: "backend"
|
||||
PENPOT_EXPORTER_SERVICE: "exporter"
|
||||
|
||||
# Internal container ports (upstream defaults)
|
||||
PENPOT_FRONTEND_INTERNAL_PORT: 8080
|
||||
PENPOT_BACKEND_INTERNAL_PORT: 6060
|
||||
PENPOT_EXPORTER_INTERNAL_PORT: 6061
|
||||
|
||||
# Public URI
|
||||
PENPOT_PUBLIC_URI: "{{ lookup('tls', application_id, 'url.base') }}"
|
||||
|
||||
# Secret
|
||||
PENPOT_SECRET_KEY: "{{ lookup('config', application_id, 'credentials.secret_key') }}"
|
||||
|
||||
# Assets storage (filesystem backend; backup-ready named volume)
|
||||
PENPOT_ASSETS_FS_DIRECTORY: "/opt/data/assets"
|
||||
|
||||
# Database (shared svc-db-postgres)
|
||||
PENPOT_DATABASE_USERNAME: "{{ lookup('database', application_id, 'username') }}"
|
||||
PENPOT_DATABASE_NAME: "{{ lookup('database', application_id, 'name') }}"
|
||||
PENPOT_DATABASE_HOST: "{{ lookup('database', application_id, 'host') }}"
|
||||
PENPOT_DATABASE_PORT: "{{ lookup('database', application_id, 'port') }}"
|
||||
PENPOT_DATABASE_PASSWORD: "{{ lookup('database', application_id, 'password') }}"
|
||||
PENPOT_DATABASE_URI: "postgresql://{{ PENPOT_DATABASE_HOST }}:{{ PENPOT_DATABASE_PORT }}/{{ PENPOT_DATABASE_NAME }}"
|
||||
|
||||
# Redis (role-local sidecar emitted by sys-svc-compose; service name == "redis")
|
||||
PENPOT_REDIS_HOST: "redis"
|
||||
PENPOT_REDIS_PORT: 6379
|
||||
PENPOT_REDIS_URI: "redis://{{ PENPOT_REDIS_HOST }}:{{ PENPOT_REDIS_PORT }}/0"
|
||||
|
||||
# Internal service URIs (frontend ↔ backend ↔ exporter)
|
||||
PENPOT_BACKEND_URI: "http://{{ PENPOT_BACKEND_SERVICE }}:{{ PENPOT_BACKEND_INTERNAL_PORT }}"
|
||||
PENPOT_EXPORTER_URI: "http://{{ PENPOT_EXPORTER_SERVICE }}:{{ PENPOT_EXPORTER_INTERNAL_PORT }}"
|
||||
|
||||
# Auth integration flags
|
||||
PENPOT_SSO_ENABLED: "{{ lookup('config', application_id, 'services.sso.enabled') }}"
|
||||
PENPOT_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enabled') }}"
|
||||
# Login/registration toggles — derived from OIDC (variant-aware via the sso
|
||||
# service flag). Native password login is off once OIDC is the login path
|
||||
# (forces SSO); registration is on under OIDC, which Penpot's JIT provisioning
|
||||
# of first-time federated users requires.
|
||||
PENPOT_NATIVE_LOGIN_ENABLED: "{{ not (PENPOT_SSO_ENABLED | bool) }}"
|
||||
PENPOT_REGISTRATION_ENABLED: "{{ PENPOT_SSO_ENABLED | bool }}"
|
||||
|
||||
# PENPOT_FLAGS — space-separated feature toggles consumed by every container.
|
||||
PENPOT_FLAGS: >-
|
||||
{{ 'enable-registration' if PENPOT_REGISTRATION_ENABLED | bool else 'disable-registration' }}
|
||||
{{ 'enable-login-with-password' if PENPOT_NATIVE_LOGIN_ENABLED | bool else 'disable-login-with-password' }}
|
||||
enable-prepl-server
|
||||
disable-telemetry
|
||||
disable-onboarding
|
||||
{{ 'enable-login-with-oidc' if PENPOT_SSO_ENABLED | bool else '' }}
|
||||
{{ 'enable-login-with-ldap' if PENPOT_LDAP_ENABLED | bool else '' }}
|
||||
|
||||
# Administrator local account — bootstrapped (tasks/main.yml) when native login is on.
|
||||
PENPOT_ADMIN_EMAIL: "{{ lookup('users', 'administrator').email }}"
|
||||
PENPOT_ADMIN_PASSWORD: "{{ lookup('users', 'administrator').password }}"
|
||||
PENPOT_ADMIN_FULLNAME: "{{ lookup('users', 'administrator').username }}"
|
||||
|
||||
# OIDC (Keycloak) — only consumed when services.sso.enabled
|
||||
PENPOT_OIDC_CLIENT_ID: "{{ OIDC.CLIENT.ID }}"
|
||||
PENPOT_OIDC_CLIENT_SECRET: "{{ OIDC.CLIENT.SECRET }}"
|
||||
PENPOT_OIDC_BASE_URI: "{{ OIDC.CLIENT.ISSUER_URL }}"
|
||||
PENPOT_OIDC_AUTH_URI: "{{ OIDC.CLIENT.AUTHORIZE_URL }}"
|
||||
PENPOT_OIDC_TOKEN_URI: "{{ OIDC.CLIENT.TOKEN_URL }}"
|
||||
PENPOT_OIDC_USER_URI: "{{ OIDC.CLIENT.USER_INFO_URL }}"
|
||||
PENPOT_OIDC_SCOPES: "openid profile email"
|
||||
|
||||
# LDAP (OpenLDAP) — only consumed when services.ldap.enabled
|
||||
PENPOT_LDAP_HOST: "{{ LDAP.SERVER.DOMAIN }}"
|
||||
PENPOT_LDAP_PORT: "{{ LDAP.SERVER.PORT }}"
|
||||
PENPOT_LDAP_BASE_DN: "{{ LDAP.DN.OU.USERS }}"
|
||||
PENPOT_LDAP_BIND_DN: "{{ LDAP.DN.ADMINISTRATOR.DATA }}"
|
||||
PENPOT_LDAP_BIND_PASSWORD: "{{ LDAP.BIND_CREDENTIAL }}"
|
||||
PENPOT_LDAP_ATTRS_USERNAME: "{{ LDAP.USER.ATTRIBUTES.ID }}"
|
||||
PENPOT_LDAP_ATTRS_EMAIL: "{{ LDAP.USER.ATTRIBUTES.MAIL }}"
|
||||
PENPOT_LDAP_ATTRS_FULLNAME: "{{ LDAP.USER.ATTRIBUTES.FULLNAME }}"
|
||||
Reference in new issue
Block a user