Compare commits

...
Author SHA1 Message Date
Linus Gasser f1a5e65ed7 chore: publish 1.0.0+v0.25.3 release 2026-11-12 17:13:06 +01:00
Linus Gasser aa322c66e7 chore: update image tags 2026-11-12 17:12:13 +01:00
Linus GasserandClaude Opus 5.5 e7aaf18d2d Upgrade to drive v0.25.3, replace MinIO with RustFS
- use rustfs instead of minio and provide migration script
- Schedule clean_pending_items/purge_deleted_items with celery beat
- Give celery workers the S3 access key secret
- Collabora admin access needs setting a password

Following the [C4DT AI Policy](https://git.coopcloud.tech/c4dt/ai-policy/src/branch/main/AI-POLICY.md):

 #LLM_vibe
 #LLM_model Claude Opus 5.5

- [x] I take full responsibility for this code
- [x] I understand every line of this code
- [x] I tested this code thoroughly on my own computer / servers
- [x] None of the commit messages were created using LLMs
- [x] I will participate in any discussion on my own and will not use an LLM for the discussion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 16:07:05 +00:00
Linus Gasser a09d912312 chore: update image tags 2026-11-06 12:21:29 +01:00
Renovate Bot cd62253078 chore(deps): update lasuite/drive-backend docker tag to v0.25.0 2026-10-20 18:38:37 +00:00
Renovate Bot 6b33c50167 chore(deps): update lasuite/drive-frontend docker tag to v0.25.0 2026-10-20 18:38:25 +00:00
Renovate Bot 546b7a2c74 chore(deps): update collabora/code docker tag to v26 2026-10-20 18:38:07 +00:00
Renovate Bot 34742958d5 chore(deps): update onlyoffice/documentserver-de docker tag to v10 2026-10-20 18:37:52 +00:00
Linus Gasser 7144606b15 Define how renovate bot updates increase versions 2026-10-20 18:37:14 +00:00
Renovate Bot 96f4c4e5e4 chore(deps): update redis docker tag to v8.10.2 2026-10-20 15:06:50 +00:00
Renovate Bot 1e7d27178d chore(deps): update nginx docker tag to v1.31.7 2026-10-20 15:06:08 +00:00
Linus Gasser ed147f2969 chore: publish 0.13.3+v0.22.0 release 2026-10-20 17:05:28 +02:00
Linus Gasser bc291566f7 Update MINIO config to v2
The previous commit forgot to update the minio-version, which makes updates fail.
2026-10-19 15:17:38 +02:00
Linus Gasser 863691f412 Merge pull request 'Configure Renovate' (#5) from renovate/configure into main
Reviewed-on: https://git.coopcloud.tech/coop-cloud/lasuite-drive/pulls/5
2026-09-30 07:30:30 +00:00
notplants a3873fa456 Merge pull request 'Update maintainers' (#15) from update_maintainers into main
Reviewed-on: https://git.coopcloud.tech/coop-cloud/lasuite-drive/pulls/15
2026-09-29 16:12:45 +00:00
Linus Gasser fecfa66ebc Update maintainers
Move maintainers to metadata and add @ineiti
2026-09-29 14:58:34 +00:00
notplants 2183d03860 Merge pull request 'docs: add recipe maintenance policy' (#14) from maintenance-policy into main
Reviewed-on: https://git.coopcloud.tech/coop-cloud/lasuite-drive/pulls/14
2026-09-29 14:38:52 +00:00
notplants 8935f1e3bd docs: add recipe maintenance policy 2026-09-29 14:35:44 +00:00
notplants 58e11f4439 chore: publish 0.13.2+v0.22.0 release 2026-09-28 16:27:00 -04:00
trav 726f4e8f28 chore: publish 0.13.1+v0.22.0 release 2026-09-28 13:12:15 -07:00
notplants e3146f3ca2 Merge pull request 'Use other minio as the quay.io is not available' (#13) from c4dt/lasuite-drive:use_other_minio into main
Reviewed-on: https://git.coopcloud.tech/coop-cloud/lasuite-drive/pulls/13
2026-09-28 19:57:10 +00:00
Linus Gasser 782e3f0965 Use other minio as the quay.io is not available
cc-ci/testme cc-ci: failure
2026-09-28 17:26:36 +02:00
trav 95489da91f chore: publish 0.13.0+v0.22.0 release 2026-09-21 10:48:22 -07:00
trav 217fb7da0d Merge pull request 'Upgrade 7f0164c' (#12) from upgrade-7f0164c into main
Reviewed-on: https://git.coopcloud.tech/coop-cloud/lasuite-drive/pulls/12
2026-09-21 17:47:58 +00:00
autonomic-bot f89cbbc046 chore: upgrade lasuite/drive to v0.22.0, mailcatcher to v0.11.0, web nginx to 1.31.6; pull MinIO from Quay
cc-ci/testme cc-ci: success
2026-09-18 03:40:21 +00:00
autonomic-bot 8324cf2730 chore: upgrade lasuite-drive app/backend/celery/celery-beat to v0.22.0
cc-ci/testme cc-ci: success
2026-09-11 04:20:22 +00:00
autonomic-bot 2662398786 chore: upgrade mailcatcher to v0.11.0, web nginx to 1.31.5
cc-ci/testme cc-ci: success
2026-09-04 02:31:09 +00:00
autonomic-bot 7f0164cb00 chore: upgrade lasuite-drive app/backend/celery/celery-beat to v0.21.2
cc-ci/testme cc-ci: success
2026-08-28 03:18:06 +00:00
trav a02dd080ac chore: publish 0.12.0+v0.21.0 release 2026-08-24 12:48:00 -04:00
trav 4d80b26a64 Merge pull request 'Upgrade 1935df0' (#11) from upgrade-1935df0 into main
Reviewed-on: https://git.coopcloud.tech/coop-cloud/lasuite-drive/pulls/11
2026-08-24 16:47:36 +00:00
autonomic-bot 8ac8c5a293 chore: upgrade lasuite-drive redis to 8.10.1, web (nginx) to 1.31.4
cc-ci/testme cc-ci: success
2026-08-21 02:57:20 +00:00
autonomic-bot a5e67e55de chore: upgrade lasuite/drive to v0.21.0
cc-ci/testme cc-ci: success
2026-08-15 19:11:40 +00:00
autonomic-bot c3b6b72232 chore: upgrade lasuite-drive redis to 8.10.0, collabora to 25.04.10.3.1
cc-ci/testme cc-ci: success
2026-08-07 04:24:16 +00:00
autonomic-bot 543a017fdd chore: pin redis to 8.8.1 (security patch; prefer over 8.10.0 minor for sidecar cache)
cc-ci/testme cc-ci testme GREEN (drone #1187)
2026-08-04 01:22:47 +00:00
autonomic-bot 077ea217e1 chore: upgrade redis to 8.10.0
cc-ci/testme cc-ci !testme: GREEN (Drone build #1186)
2026-08-04 01:13:16 +00:00
autonomic-bot ed2617026b chore: upgrade redis to 8.8.1 2026-08-04 00:59:32 +00:00
autonomic-bot 9beb089d10 chore: revert collabora to 25.04.9.4.1 (26.04.2.x crashes in-deploy: sh not found in $PATH)
cc-ci/testme cc-ci: success
2026-07-24 08:35:16 +00:00
autonomic-bot 1e578b0712 chore: upgrade drive to v0.20.0, web (nginx) to 1.31.3
cc-ci/testme cc-ci: failure
2026-07-24 04:50:01 +00:00
autonomic-bot 119bbae335 chore: upgrade collabora to 26.04.2.1.1, onlyoffice to 9.4.1.2
cc-ci/testme cc-ci: failure
2026-07-03 05:04:46 +00:00
autonomic-bot 1935df02e7 chore: upgrade collabora to 25.04.10.3.1, onlyoffice to 9.4.1.1
cc-ci/testme cc-ci: success
2026-06-29 00:48:20 +00:00
notplants e39f0e8ca3 chore: publish 0.11.0+v0.19.0 release 2026-06-23 16:02:40 -04:00
notplants d9adb88d40 Merge pull request 'Add auto-migrations to lasuite-drive' (#10) from pr-20260623-193109 into main
Reviewed-on: https://git.coopcloud.tech/coop-cloud/lasuite-drive/pulls/10
2026-06-23 19:57:53 +00:00
notplants 9e4c194783 create minio bucket automatically on startup (minio-initialize.sh)
cc-ci/testme cc-ci: success
Replace the one-shot minio-createbuckets service (which required a
manual 'abra app restart minio-createbuckets' that appeared to hang)
with a minio-initialize.sh config, ported from lasuite-docs. The minio
service entrypoint runs it in the background before starting minio; it
waits for minio to be ready and idempotently creates the
drive-media-storage bucket with versioning. Manual trigger:
'abra app cmd minio minio_initialize'.
2026-06-23 19:26:19 +00:00
notplants 0b64135f98 run migrations automatically on backend startup (AUTO_MIGRATIONS)
Add a migrate.sh config (ported from lasuite-docs) that the backend
entrypoint runs when AUTO_MIGRATIONS=true (default). It loads secrets,
waits for the DB, and idempotently applies pending migrations via
'migrate --check' then 'migrate --noinput'. The manual 'abra app cmd
backend migrate' now delegates to the same script.
2026-06-23 19:13:39 +00:00
trav d3c3682936 chore: publish 0.10.1+v0.19.0 release 2026-06-22 14:54:08 -04:00
trav a06114d855 Merge pull request 'chore: upgrade web (nginx) to 1.31.2' (#9) from upgrade-0e3daef into main
Reviewed-on: https://git.coopcloud.tech/coop-cloud/lasuite-drive/pulls/9
2026-06-22 18:46:47 +00:00
Renovate Bot 802d7e8477 Add renovate.json 2026-03-10 17:51:03 +00:00
14 changed files with 487 additions and 117 deletions
+41 -8
View File
@@ -1,7 +1,8 @@
TYPE=lasuite-drive
DOMAIN=lasuite-drive.example.com
MINIO_DOMAIN="minio.${DOMAIN}"
# public domain of the RustFS object storage (S3)
RUSTFS_DOMAIN="rustfs.${DOMAIN}"
COLLABORA_DOMAIN="collabora.${DOMAIN}"
ONLY_OFFICE_DOMAIN="onlyoffice.${DOMAIN}"
@@ -20,16 +21,14 @@ SECRET_DJANGO_SK_VERSION=v1
SECRET_OIDC_RPCS_VERSION=v1
# DJANGO_SUPERUSER_PASSWORD
SECRET_DJANGO_SP_VERSION=v1
# MINIO_ROOT_PASSWORD
SECRET_MINIO_RP_VERSION=v1
# MINIO_ROOT_USER
SECRET_MINIO_RU_VERSION=v1
# RUSTFS_SECRET_KEY
SECRET_RUSTFS_RP_VERSION=v1
# RUSTFS_ACCESS_KEY
SECRET_RUSTFS_RU_VERSION=v1
# POSTGRES_PASSWORD
SECRET_POSTGRES_P_VERSION=v1
# DJANGO_HOST_EMAIL_PASSWORD
SECRET_EMAIL_PASS_VERSION=v1
# COLLABORA_ADMIN_PASSWORD
SECRET_COLLABORA_P_VERSION=v1
##############################################################################
# EMAIL
@@ -68,12 +67,21 @@ LOGGING_LEVEL_HANDLERS_CONSOLE=INFO
LOGGING_LEVEL_LOGGERS_ROOT=INFO
LOGGING_LEVEL_LOGGERS_APP=INFO
##############################################################################
# MIGRATIONS
##############################################################################
# Set to false to disable automatic migrations on backend startup
# AUTO_MIGRATIONS=true
##############################################################################
# COLLABORA ADMIN PANEL
##############################################################################
# Username for the Collabora admin panel (https://COLLABORA_DOMAIN/browser/dist/admin/admin.html)
# Password is managed via Docker secret 'collabora_p'
# The distroless collabora image (>= 26.04) cannot read Docker secrets, so the
# password is passed as a plain env var (visible in `docker service inspect`).
# The admin panel is unusable while it is unset.
#COLLABORA_ADMIN_USERNAME=admin
#COLLABORA_ADMIN_PASSWORD=
##############################################################################
# BACKUPS
@@ -81,6 +89,31 @@ LOGGING_LEVEL_LOGGERS_APP=INFO
# Set to false to disable backup-bot labels (default: true)
#ENABLE_BACKUPS=true
##############################################################################
# MAINTENANCE SCHEDULING
##############################################################################
# Celery Beat crontab (m h dom mon dow) followed by the command arguments
# Clean up uploads that never finished (default: daily at 0:30)
#CLEAN_PENDING_ITEMS_SCHEDULE="30 0 * * * --hours=48"
# Permanently purge items deleted from the trash (default: daily at 0:45)
#PURGE_DELETED_ITEMS_SCHEDULE="45 0 * * *"
##############################################################################
# SDK
##############################################################################
# Enable the SDK picker pages (/sdk) and relay API used by other La Suite apps
#SDK_ENABLED=false
##############################################################################
# MINIO MIGRATION
##############################################################################
# Only needed once when upgrading from recipe versions using MinIO (<= 0.13.x),
# see README "Migrating from MinIO"
#COMPOSE_FILE="compose.yml:compose.minio-migration.yml"
# versions of the old MinIO secrets, keep them from your previous config
#SECRET_MINIO_RP_VERSION=v1
#SECRET_MINIO_RU_VERSION=v1
##############################################################################
# WOPI SCHEDULING
##############################################################################
+29
View File
@@ -0,0 +1,29 @@
# Lasuite-Drive Recipe Maintenance
All contributions should be made via pull requests.
## Pull Requests
A pull request can be merged directly into `main` by any maintainer.
No approval is needed, but pull requests should be tested as working before merge.
## Dependency Updates
- patch updates proposed from renovate bot are merged directly -
if there is a CVE, also release a new patch version of this recipe
- minor updates proposed from renovate bot are tested by one maintainer
and merged if they don't break, then a new minor version of the recipe
is released
- major updates proposed form renovate bot are tested by two maintainers
and merged if they don't break, then a major version of the recipe is
released.
If the renovate bot PR is older than one week, one maintainer can decide
to merge it anyway.
## Become a maintainer
Everyone can apply to be a recipe maintainer:
1. Watch the repository to get updates.
2. Add yourself to the list in [README.md](./README.md) and open a pull request with the change.
3. Once the pull request is merged, you will be added to the [La Suite maintainers team](https://git.coopcloud.tech/org/coop-cloud/teams/lasuite-maintainers).
+48 -7
View File
@@ -3,7 +3,7 @@
> [Drive](https://github.com/suitenumerique/drive) (part of [La Suite Numerique](https://lasuite.numerique.gouv.fr/en)) for Co-op Cloud
<!-- metadata -->
* **Maintainer**: [@trav](https://git.coopcloud.tech/trav), [@notplants](https://git.coopcloud.tech/notplants), [@ineiti](https://git.coopcloud.tech/ineiti)
* **Category**: Apps
* **Status**: 2
* **Image**: [`lasuite/drive`](https://hub.docker.com/r/lasuite/drive), 4, upstream
@@ -19,24 +19,53 @@
### Setting Up Domains
This recipe requires four domains. One domain for drive, and one for minio which also needs its own public endpoint (see [https://github.com/suitenumerique/drive/issues/476](https://github.com/suitenumerique/drive/issues/476)), one domain for collabora, and one domain for onlyoffice. For example (`drive.yourdomain.tld`, `minio.drive.yourdomain.tld`, `collabora.drive.yourdomain.tld`, `onlyoffice.drive.yourdomain.tld`). All of these domains need to have an A record pointing the IP address of your server.
This recipe requires four domains. One domain for drive, one for the S3 object storage ([RustFS](https://rustfs.com)) which also needs its own public endpoint (see [https://github.com/suitenumerique/drive/issues/476](https://github.com/suitenumerique/drive/issues/476)), one domain for collabora, and one domain for onlyoffice. For example (`drive.yourdomain.tld`, `rustfs.drive.yourdomain.tld`, `collabora.drive.yourdomain.tld`, `onlyoffice.drive.yourdomain.tld`). All of these domains need to have an A record pointing the IP address of your server.
### Installation Steps
* `abra app new lasuite-drive --secrets`
* `abra app config <app-name>`
- make sure to set MINIO_DOMAIN, COLLABORA_DOMAIN, ONLY_OFFICE_DOMAIN to the domains you set up for each.
- make sure to set RUSTFS_DOMAIN, COLLABORA_DOMAIN, ONLY_OFFICE_DOMAIN to the domains you set up for each.
* `abra app deploy <app-name>`
* `abra app cmd <app-name> backend migrate` # creates database tables
* `abra app restart <app-name> minio-createbuckets` (Note: this will appear to fail, but probably worked! Check `abra app logs <app-name> minio-createbuckets`)
You should then be able to visit the landing page of your app, but not yet to login. To login, you need to deploy and integrate single sign on (described below in the "Configure Authentication" section).
* Migrations run automatically on backend startup. To trigger manually: `abra app cmd <app-name> backend migrate`
* The S3 bucket is created automatically on backend startup. To manually trigger: `abra app cmd <app-name> backend s3_initialize`
Wopi discovery is supposed to happen automatically, but if collabora/onlyoffice are not connecting, you can try running:
* `abra app cmd <app-name> backend trigger_wopi` # connects only office & collabora (if they stop working, try running this again)
Maintenance tasks (cleaning unfinished uploads, purging the trash) are scheduled by celery beat, see `CLEAN_PENDING_ITEMS_SCHEDULE` and `PURGE_DELETED_ITEMS_SCHEDULE` in `.env.sample`.
## Migrating from MinIO
Since upstream drive v0.24, and in this recipe since the version packaging drive v0.25, the S3 object storage is [RustFS](https://rustfs.com) instead of MinIO. RustFS cannot read the MinIO data directory, so the media files have to be copied once. RustFS uses new secrets and a new domain variable.
1. `abra app config <app-name>` and add:
```
RUSTFS_DOMAIN="${MINIO_DOMAIN}"
SECRET_RUSTFS_RP_VERSION=v1
SECRET_RUSTFS_RU_VERSION=v1
COMPOSE_FILE="compose.yml:compose.minio-migration.yml"
```
Keep `MINIO_DOMAIN`, `SECRET_MINIO_RP_VERSION` and `SECRET_MINIO_RU_VERSION`, they are still needed during the migration. The `COMPOSE_FILE` line temporarily runs the old MinIO service on the old `minio` volume, next to RustFS.
2. Generate the RustFS credentials:
```
abra app secret generate <app-name> rustfs_ru v1
abra app secret generate <app-name> rustfs_rp v1
```
3. `abra app upgrade <app-name>` (or `abra app deploy <app-name> --force`)
4. `abra app cmd <app-name> backend migrate_from_minio`
This copies all objects (latest version only) from MinIO to RustFS. Objects that already exist on RustFS are never overwritten, so if the copy is interrupted, just run it again. Once a run has completed, further runs are refused: they would copy back files that users deleted in the meantime. If you really need to, run `abra app cmd <app-name> backend migrate_from_minio -- --force`.
5. Check that your files can be opened in drive, then remove the `COMPOSE_FILE` line from the config and redeploy: `abra app deploy <app-name> --force`. If you like, replace `RUSTFS_DOMAIN="${MINIO_DOMAIN}"` with the actual domain and remove `MINIO_DOMAIN`, `SECRET_MINIO_RP_VERSION` and `SECRET_MINIO_RU_VERSION`.
6. Once you are sure everything is there, you can remove the old volume and secrets on the server: `docker volume rm <stack-name>_minio`, `abra app secret rm <app-name> minio_ru` and `abra app secret rm <app-name> minio_rp`
Until step 4 is done, files uploaded before the upgrade will not be accessible.
## Configure Authentication
lasuite-drive **requires** an OpenID Connect (OIDC) single sign-on provider; deployment has been tested with [Keycloak](https://git.coopcloud.tech/coop-cloud/keycloak), which we recommend, or you could also try [Authentik](https://git.coopcloud.tech/coop-cloud/authentik), both of which are installable using Co-op Cloud.
@@ -92,7 +121,19 @@ Then redeploy the app, and automated e-mail sending should work:
`abra app deploy <app-name> --force`
## Configure the Collabora Admin Panel
## Maintainers
Collabora has a web admin panel at `https://<COLLABORA_DOMAIN>/browser/dist/admin/admin.html`. It is disabled until a password is set. Using `abra app config <app-name>`, set:
coop cloud recipe maintained by @notplants
```
COLLABORA_ADMIN_USERNAME=admin
COLLABORA_ADMIN_PASSWORD=<yourcollaborapass>
```
Then redeploy the app:
`abra app deploy <app-name> --force`
Since version 26.04, the Collabora image is distroless: there is no shell to read a Docker secret, so the password is passed as a plain environment variable. It is visible to anyone who can run `docker service inspect` on the swarm. Leave `COLLABORA_ADMIN_PASSWORD` unset if you don't need the admin panel.
If you are upgrading from a version that used the `collabora_p` secret, that secret is no longer used: remove `SECRET_COLLABORA_P_VERSION` from your config, and optionally the secret itself with `abra app secret remove <app-name> collabora_p`.
+2 -2
View File
@@ -3,8 +3,8 @@ set -e
[ -f /run/secrets/postgres_p ] && export DB_PASSWORD="$(cat /run/secrets/postgres_p)"
[ -f /run/secrets/django_sk ] && export DJANGO_SECRET_KEY="$(cat /run/secrets/django_sk)"
[ -f /run/secrets/minio_rp ] && export AWS_S3_SECRET_ACCESS_KEY="$(cat /run/secrets/minio_rp)"
[ -f /run/secrets/minio_ru ] && export AWS_S3_ACCESS_KEY_ID="$(cat /run/secrets/minio_ru)"
[ -f /run/secrets/rustfs_rp ] && export AWS_S3_SECRET_ACCESS_KEY="$(cat /run/secrets/rustfs_rp)"
[ -f /run/secrets/rustfs_ru ] && export AWS_S3_ACCESS_KEY_ID="$(cat /run/secrets/rustfs_ru)"
[ -f /run/secrets/django_sp ] && export DJANGO_SUPERUSER_PASSWORD="$(cat /run/secrets/django_sp)"
[ -f /run/secrets/oidc_rpcs ] && export OIDC_RP_CLIENT_SECRET="$(cat /run/secrets/oidc_rpcs)"
[ -f /run/secrets/email_pass ] && export DJANGO_EMAIL_HOST_PASSWORD="$(cat /run/secrets/email_pass)"
+16 -5
View File
@@ -1,9 +1,12 @@
# Set any config versions here
# Docs: https://docs.coopcloud.tech/maintainers/handbook/#manage-configs
export ABRA_ENTRYPOINT_VERSION=v11
export NGINX_CONF_VERSION=v6
export ONLYOFFICE_CONF_VERSION=v2
export ABRA_ENTRYPOINT_VERSION=v12
export NGINX_CONF_VERSION=v7
export ONLYOFFICE_CONF_VERSION=v3
export PG_BACKUP_VERSION=v4
export MIGRATE_VERSION=v1
export S3_INITIALIZE_VERSION=v1
export MIGRATE_FROM_MINIO_VERSION=v1
environment() {
# this exports all the secrets as environment variables
@@ -11,8 +14,16 @@ environment() {
}
migrate() {
environment
python manage.py migrate --noinput
/migrate.sh
}
s3_initialize() {
/s3-initialize.sh
}
# only available when deployed with compose.minio-migration.yml
migrate_from_minio() {
/migrate-from-minio.sh "$@"
}
trigger_wopi() {
+47
View File
@@ -0,0 +1,47 @@
---
# Temporary overlay to migrate the media files from the old MinIO service
# (recipe versions up to 0.13.x) to RustFS. See README "Migrating from MinIO".
services:
minio:
image: bitnamilegacy/minio:2025.7.23-debian-12-r5
user: root
environment:
MINIO_ROOT_USER_FILE: /run/secrets/minio_ru
MINIO_ROOT_PASSWORD_FILE: /run/secrets/minio_rp
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 20s
retries: 60
networks:
- backend
volumes:
- minio:/bitnami/minio/data
secrets:
- minio_rp
- minio_ru
backend:
secrets:
- minio_rp
- minio_ru
configs:
- source: migrate_from_minio
target: /migrate-from-minio.sh
mode: 0555
volumes:
minio:
configs:
migrate_from_minio:
name: ${STACK_NAME}_migrate_from_minio_${MIGRATE_FROM_MINIO_VERSION}
file: migrate-from-minio.sh
secrets:
minio_rp:
external: true
name: ${STACK_NAME}_minio_rp_${SECRET_MINIO_RP_VERSION}
minio_ru:
external: true
name: ${STACK_NAME}_minio_ru_${SECRET_MINIO_RU_VERSION}
+81 -88
View File
@@ -34,10 +34,10 @@ x-common-env: &common-env
DRIVE_BASE_URL: "https://${DOMAIN}"
# Media
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL: http://minio:9000
AWS_S3_DOMAIN_REPLACE: https://${MINIO_DOMAIN}
# AWS_S3_ACCESS_KEY_ID supplied via secret (this is same MINIO_ROOT_USER)
# AWS_S3_SECRET_ACCESS_KEY supplied via secret (this is same as MINIO_ROOT_PASSWORD)
AWS_S3_ENDPOINT_URL: http://rustfs:9000
AWS_S3_DOMAIN_REPLACE: https://${RUSTFS_DOMAIN}
# AWS_S3_ACCESS_KEY_ID supplied via secret (this is same as RUSTFS_ACCESS_KEY)
# AWS_S3_SECRET_ACCESS_KEY supplied via secret (this is same as RUSTFS_SECRET_KEY)
MEDIA_BASE_URL: https://${DOMAIN}
AWS_STORAGE_BUCKET_NAME: drive-media-storage
# OIDC - settings from .env, see .env.sample
@@ -63,6 +63,8 @@ x-common-env: &common-env
AI_MODEL: llama
# Collaboration
COLLABORATION_API_URL: https://$DOMAIN/collaboration/api/
# SDK picker pages (/sdk) and SDK relay API, used by other La Suite apps
SDK_ENABLED: ${SDK_ENABLED:-false}
# WOPI
WOPI_CLIENTS: "collabora,onlyoffice"
@@ -87,22 +89,23 @@ x-postgres-env: &postgres-env
DB_PORT: 5432
# DB_PASSWORD supplied via secrets (this is same as POSTGRES_PASSWORD)
x-minio-env: &minio-env
MINIO_ROOT_USER_FILE: /run/secrets/minio_ru
MINIO_ROOT_PASSWORD_FILE: /run/secrets/minio_rp
x-rustfs-env: &rustfs-env
RUSTFS_ACCESS_KEY_FILE: /run/secrets/rustfs_ru
RUSTFS_SECRET_KEY_FILE: /run/secrets/rustfs_rp
RUSTFS_CONSOLE_ENABLE: "false"
services:
app:
user: "${DOCKER_USER:-1000}"
image: lasuite/drive-frontend:v0.19.0
image: lasuite/drive-frontend:v0.25.3
networks:
- backend
deploy:
labels:
- "traefik.enable=false"
- "coop-cloud.${STACK_NAME}.timeout=${TIMEOUT:-120}"
- "coop-cloud.${STACK_NAME}.version=0.10.0+v0.19.0"
- "coop-cloud.${STACK_NAME}.version=1.0.0+v0.25.3"
environment:
<<: [ *common-env ]
healthcheck:
@@ -116,11 +119,14 @@ services:
backend:
user: ${DOCKER_USER:-1000}
image: lasuite/drive-backend:v0.19.0
image: lasuite/drive-backend:v0.25.3
command: [ "gunicorn", "-c", "/usr/local/etc/gunicorn/drive.py", "drive.wsgi:application" ]
entrypoint: [ "/abra-entrypoint.sh", "/usr/local/bin/entrypoint" ]
entrypoint: >
sh -c "/s3-initialize.sh || echo '⚠ s3-initialize failed, run: abra app cmd <app> backend s3_initialize' ;
if [ \"$$AUTO_MIGRATIONS\" = \"true\" ]; then /migrate.sh; fi && exec /abra-entrypoint.sh /usr/local/bin/entrypoint \"$$@\"" --
environment:
<<: [ *common-env, *postgres-env ]
AUTO_MIGRATIONS: "${AUTO_MIGRATIONS:-true}"
networks:
- backend
depends_on:
@@ -129,18 +135,24 @@ services:
- source: abra_entrypoint
target: /abra-entrypoint.sh
mode: 0555
- source: migrate
target: /migrate.sh
mode: 0555
- source: s3_initialize
target: /s3-initialize.sh
mode: 0555
secrets:
- django_sk
- django_sp
- oidc_rpcs
- minio_rp
- minio_ru
- rustfs_rp
- rustfs_ru
- postgres_p
- email_pass
celery:
user: ${DOCKER_USER:-1000}
image: lasuite/drive-backend:v0.19.0
image: lasuite/drive-backend:v0.25.3
networks:
- backend
command: [ "celery", "-A", "drive.celery_app", "worker", "-l", "INFO" ]
@@ -156,19 +168,23 @@ services:
- django_sk
- django_sp
- oidc_rpcs
- minio_rp
- rustfs_rp
- rustfs_ru
- postgres_p
- email_pass
celery-beat:
user: ${DOCKER_USER:-1000}
image: lasuite/drive-backend:v0.19.0
image: lasuite/drive-backend:v0.25.3
networks:
- backend
command: [ "celery", "-A", "drive.celery_app", "beat", "-l", "INFO", "--schedule", "/tmp/celerybeat-schedule" ]
entrypoint: ["/abra-entrypoint.sh", "/usr/local/bin/entrypoint"]
environment:
<<: [*common-env, *postgres-env]
# Maintenance commands scheduled by celery beat: crontab expression followed by command arguments
CLEAN_PENDING_ITEMS_SCHEDULE: ${CLEAN_PENDING_ITEMS_SCHEDULE:-30 0 * * *}
PURGE_DELETED_ITEMS_SCHEDULE: ${PURGE_DELETED_ITEMS_SCHEDULE:-45 0 * * *}
configs:
- source: abra_entrypoint
target: /abra-entrypoint.sh
@@ -177,7 +193,8 @@ services:
- django_sk
- django_sp
- oidc_rpcs
- minio_rp
- rustfs_rp
- rustfs_ru
- postgres_p
- email_pass
@@ -209,7 +226,7 @@ services:
- postgres_p
redis:
image: redis:8.8.0
image: redis:8.10.3
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
@@ -219,80 +236,52 @@ services:
- backend
mailcatcher:
image: sj26/mailcatcher:v0.10.0
image: sj26/mailcatcher:v0.11.0
networks:
- backend
minio-createbuckets:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
environment: *minio-env
entrypoint: >
sh -c "
MINIO_ROOT_USER=\"\$$(cat /run/secrets/minio_ru)\" &&
MINIO_ROOT_PASSWORD=\"\$$(cat /run/secrets/minio_rp)\" &&
/usr/bin/mc alias set drive http://minio:9000 \$${MINIO_ROOT_USER} \"\$${MINIO_ROOT_PASSWORD}\" && \
/usr/bin/mc mb drive/drive-media-storage && \
/usr/bin/mc version enable drive/drive-media-storage && \
exit 0;"
deploy:
mode: replicated
replicas: 0
restart_policy:
condition: none
secrets:
- minio_rp
- minio_ru
networks:
- backend
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
environment: *minio-env
rustfs:
image: rustfs/rustfs:1.0.5
environment: *rustfs-env
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
test: ["CMD", "curl", "-sf", "http://localhost:9000/health"]
interval: 5s
timeout: 20s
retries: 300
retries: 60
networks:
- backend
- proxy
command: minio server /data
entrypoint: ["/usr/bin/docker-entrypoint.sh"]
volumes:
- minio:/data
configs:
- source: abra_entrypoint
target: /abra-entrypoint.sh
mode: 0555
- rustfs:/data
secrets:
- minio_rp
- minio_ru
- rustfs_rp
- rustfs_ru
deploy:
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.${STACK_NAME}_minio.rule=Host(`${MINIO_DOMAIN}`)"
- "traefik.http.routers.${STACK_NAME}_minio.entrypoints=web-secure"
- "traefik.http.routers.${STACK_NAME}_minio.tls=true"
- "traefik.http.routers.${STACK_NAME}_minio.tls.certresolver=${LETS_ENCRYPT_ENV}"
- "traefik.http.services.${STACK_NAME}_minio.loadbalancer.server.port=9000"
- "traefik.http.middlewares.${STACK_NAME}_minio-cors.headers.accessControlAllowOriginList=https://${DOMAIN}"
- "traefik.http.middlewares.${STACK_NAME}_minio-cors.headers.accessControlAllowMethods=GET,POST,PUT,DELETE,OPTIONS"
- "traefik.http.middlewares.${STACK_NAME}_minio-cors.headers.accessControlAllowHeaders=*"
- "traefik.http.middlewares.${STACK_NAME}_minio-cors.headers.accessControlExposeHeaders=ETag,Content-Length"
- "traefik.http.middlewares.${STACK_NAME}_minio-cors.headers.accessControlMaxAge=600"
- "traefik.http.middlewares.${STACK_NAME}_minio-cors.headers.addVaryHeader=true"
- "traefik.http.routers.${STACK_NAME}_minio.middlewares=${STACK_NAME}_minio-cors"
- "traefik.http.routers.${STACK_NAME}_rustfs.rule=Host(`${RUSTFS_DOMAIN}`)"
- "traefik.http.routers.${STACK_NAME}_rustfs.entrypoints=web-secure"
- "traefik.http.routers.${STACK_NAME}_rustfs.tls=true"
- "traefik.http.routers.${STACK_NAME}_rustfs.tls.certresolver=${LETS_ENCRYPT_ENV}"
- "traefik.http.services.${STACK_NAME}_rustfs.loadbalancer.server.port=9000"
- "traefik.http.middlewares.${STACK_NAME}_rustfs-cors.headers.accessControlAllowOriginList=https://${DOMAIN}"
- "traefik.http.middlewares.${STACK_NAME}_rustfs-cors.headers.accessControlAllowMethods=GET,POST,PUT,DELETE,OPTIONS"
- "traefik.http.middlewares.${STACK_NAME}_rustfs-cors.headers.accessControlAllowHeaders=*"
- "traefik.http.middlewares.${STACK_NAME}_rustfs-cors.headers.accessControlExposeHeaders=ETag,Content-Length"
- "traefik.http.middlewares.${STACK_NAME}_rustfs-cors.headers.accessControlMaxAge=600"
- "traefik.http.middlewares.${STACK_NAME}_rustfs-cors.headers.addVaryHeader=true"
- "traefik.http.routers.${STACK_NAME}_rustfs.middlewares=${STACK_NAME}_rustfs-cors"
- "backupbot.backup=${ENABLE_BACKUPS:-true}"
collabora:
image: collabora/code:25.04.9.4.1
entrypoint: >
sh -c "
export password=\"$$(cat /run/secrets/collabora_p)\" &&
exec /start-collabora-online.sh"
image: collabora/code:26.04.6.1.1
# Since 26.04 the image is distroless (no sh, no curl, no
# start-collabora-online.sh): keep the image entrypoint
# (coolwsd --use-env-vars), which reads the env vars below directly.
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:9980/hosting/discovery" ]
# --use-env-vars so the probe honours ssl.enable=false from extra_params
test: [ "CMD", "/usr/bin/coolwsd", "--probe", "--use-env-vars" ]
interval: 30s
retries: 5
start_period: 60s
@@ -303,9 +292,8 @@ services:
environment:
- extra_params=--o:ssl.enable=false --o:ssl.termination=true
- username=${COLLABORA_ADMIN_USERNAME:-admin}
- password=${COLLABORA_ADMIN_PASSWORD}
- server_name=${COLLABORA_DOMAIN}
secrets:
- collabora_p
deploy:
labels:
- "traefik.enable=true"
@@ -324,7 +312,7 @@ services:
- "traefik.http.routers.${STACK_NAME}_collabora.middlewares=${STACK_NAME}_collabora-cors"
onlyoffice:
image: onlyoffice/documentserver-de:9.3.1.2
image: onlyoffice/documentserver-de:10.0.2.1
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost/hosting/discovery" ]
interval: 30s
@@ -335,6 +323,9 @@ services:
TZ: "Europe/Berlin"
USE_UNAUTHORIZED_STORAGE: "true"
ONLY_OFFICE_DOMAIN: ${ONLY_OFFICE_DOMAIN} # need to make variable available for golang template
# onlyoffice >= 10.0.2 only sends WOPI requests to hosts named in an
# ipfilter rule (wopi.requireIpFilterRule), see onlyoffice-config.json.tmpl
DOMAIN: ${DOMAIN}
networks:
- backend
- proxy
@@ -357,13 +348,12 @@ services:
- "traefik.http.middlewares.${STACK_NAME}_onlyoffice-cors.headers.accessControlExposeHeaders=ETag,Content-Length"
- "traefik.http.middlewares.${STACK_NAME}_onlyoffice-cors.headers.accessControlMaxAge=600"
- "traefik.http.middlewares.${STACK_NAME}_onlyoffice-cors.headers.addVaryHeader=true"
- "traefik.http.routers.${STACK_NAME}_onlyoffice.middlewares=${STACK_NAME}_onlyoffice-cors"
- "traefik.http.middlewares.${STACK_NAME}_onlyoffice-fwdproto.headers.customRequestHeaders.X-Forwarded-Proto=https"
- "traefik.http.routers.${STACK_NAME}_onlyoffice.middlewares=${STACK_NAME}_onlyoffice-fwdproto"
- "traefik.http.routers.${STACK_NAME}_onlyoffice.middlewares=${STACK_NAME}_onlyoffice-cors,${STACK_NAME}_onlyoffice-fwdproto"
web:
image: nginx:1.31.2
image: nginx:1.31.8
configs:
- source: nginx_conf
target: /etc/nginx/conf.d/default.conf
@@ -392,7 +382,7 @@ networks:
volumes:
postgres:
minio:
rustfs:
configs:
nginx_conf:
@@ -405,6 +395,12 @@ configs:
abra_entrypoint:
name: ${STACK_NAME}_entrypoint_${ABRA_ENTRYPOINT_VERSION}
file: abra-entrypoint.sh
migrate:
name: ${STACK_NAME}_migrate_${MIGRATE_VERSION}
file: migrate.sh
s3_initialize:
name: ${STACK_NAME}_s3_initialize_${S3_INITIALIZE_VERSION}
file: s3-initialize.sh
onlyoffice_conf:
name: ${STACK_NAME}_onlyoffice_conf_${ONLYOFFICE_CONF_VERSION}
file: onlyoffice-config.json.tmpl
@@ -423,15 +419,12 @@ secrets:
postgres_p:
external: true
name: ${STACK_NAME}_postgres_p_${SECRET_POSTGRES_P_VERSION}
minio_rp:
rustfs_rp:
external: true
name: ${STACK_NAME}_minio_rp_${SECRET_MINIO_RP_VERSION}
minio_ru:
name: ${STACK_NAME}_rustfs_rp_${SECRET_RUSTFS_RP_VERSION}
rustfs_ru:
external: true
name: ${STACK_NAME}_minio_ru_${SECRET_MINIO_RU_VERSION}
collabora_p:
external: true
name: ${STACK_NAME}_collabora_p_${SECRET_COLLABORA_P_VERSION}
name: ${STACK_NAME}_rustfs_ru_${SECRET_RUSTFS_RU_VERSION}
email_pass:
external: true
name: ${STACK_NAME}_email_pass_${SECRET_EMAIL_PASS_VERSION}
+96
View File
@@ -0,0 +1,96 @@
#!/bin/sh
set -e
# One-shot copy of all objects from the old MinIO service (deployed with
# compose.minio-migration.yml) into the RustFS object storage.
# Only the latest version of each object is copied, and only objects missing
# on RustFS: objects changed on RustFS since (e.g. edited documents) are never
# overwritten. Once a run completes, a marker object is written and further
# runs are refused, unless called with --force.
# Load secrets into environment
source /abra-entrypoint.sh -e
python - "$@" <<'PYEOF'
import os
import sys
import boto3
from botocore.exceptions import ClientError
bucket = os.environ.get("AWS_STORAGE_BUCKET_NAME", "drive-media-storage")
marker = "_minio-migration-done"
force = "--force" in sys.argv[1:]
def secret(name):
with open(f"/run/secrets/{name}") as f:
return f.read().strip()
def client(endpoint, access_key, secret_key):
return boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=os.environ.get("AWS_S3_REGION_NAME", "us-east-1"),
)
def exists(s3, key):
try:
s3.head_object(Bucket=bucket, Key=key)
return True
except ClientError as error:
if error.response["Error"]["Code"] in ("404", "NoSuchKey", "NotFound"):
return False
raise
# the old MinIO uses the minio_* secrets, RustFS the rustfs_* secrets
src = client(
os.environ.get("MINIO_MIGRATION_ENDPOINT_URL", "http://minio:9000"),
secret("minio_ru"),
secret("minio_rp"),
)
dst = client(
os.environ["AWS_S3_ENDPOINT_URL"],
os.environ["AWS_S3_ACCESS_KEY_ID"],
os.environ["AWS_S3_SECRET_ACCESS_KEY"],
)
if exists(dst, marker) and not force:
sys.exit(
"migrate-from-minio: the migration already completed, refusing to run again.\n"
"Objects deleted on RustFS since then would be copied back from MinIO.\n"
"Use 'migrate_from_minio --force' to run anyway (existing objects are never overwritten)."
)
try:
src.head_bucket(Bucket=bucket)
except Exception as error:
sys.exit(f"migrate-from-minio: cannot reach bucket '{bucket}' on minio: {error}")
copied = skipped = 0
for page in src.get_paginator("list_objects_v2").paginate(Bucket=bucket):
for obj in page.get("Contents", []):
key = obj["Key"]
# upload_fileobj is atomic: an object only exists once fully copied
if exists(dst, key):
skipped += 1
continue
source = src.get_object(Bucket=bucket, Key=key)
extra = {"ContentType": source.get("ContentType", "binary/octet-stream")}
if source.get("Metadata"):
extra["Metadata"] = source["Metadata"]
if source.get("ContentDisposition"):
extra["ContentDisposition"] = source["ContentDisposition"]
dst.upload_fileobj(source["Body"], bucket, key, ExtraArgs=extra)
copied += 1
if copied % 100 == 0:
print(f"migrate-from-minio: {copied} objects copied...")
dst.put_object(Bucket=bucket, Key=marker, Body=b"")
print(f"migrate-from-minio: done, {copied} copied, {skipped} already present")
PYEOF
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -e
# Load secrets into environment
source /abra-entrypoint.sh -e
# Wait for database to be ready (up to 30 seconds)
i=0
while ! python manage.py check --database default 2>/dev/null; do
i=$((i+1))
if [ "$i" -ge 30 ]; then
echo "migrate: timed out waiting for database" >&2
exit 1
fi
sleep 1
done
# Idempotent: skip if no pending migrations
if python manage.py migrate --check > /dev/null 2>&1; then
echo "migrate: no pending migrations, skipping"
exit 0
fi
echo "migrate: applying pending migrations..."
python manage.py migrate --noinput
echo "migrate: done"
+6 -6
View File
@@ -56,9 +56,9 @@ server {
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/drive-media-storage/;
proxy_set_header Host minio:9000;
# Get resource from the RustFS object storage
proxy_pass http://rustfs:9000/drive-media-storage/;
proxy_set_header Host rustfs:9000;
}
# Proxy auth for media-preview
@@ -74,9 +74,9 @@ server {
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/drive-media-storage/;
proxy_set_header Host minio:9000;
# Get resource from the RustFS object storage
proxy_pass http://rustfs:9000/drive-media-storage/;
proxy_set_header Host rustfs:9000;
}
location /media-auth {
+10
View File
@@ -1,4 +1,14 @@
{
"services": {
"CoAuthoring": {
"ipfilter": {
"rules": [
{"address": "{{ env "DOMAIN" }}", "allowed": true},
{"address": "*", "allowed": true}
]
}
}
},
"wopi": {
"enable": true,
"host": "https://{{ env "ONLY_OFFICE_DOMAIN" }}",
+27
View File
@@ -0,0 +1,27 @@
Upgrade to drive v0.25.3.
**Breaking change:** following upstream, the S3 object storage changed from MinIO (`bitnamilegacy/minio`, unmaintained) to RustFS (`rustfs/rustfs`). RustFS cannot read the MinIO data, so existing media files must be copied once:
```
abra app config <app-domain> # add:
# RUSTFS_DOMAIN="${MINIO_DOMAIN}"
# SECRET_RUSTFS_RP_VERSION=v1
# SECRET_RUSTFS_RU_VERSION=v1
# COMPOSE_FILE="compose.yml:compose.minio-migration.yml"
abra app secret generate <app-domain> rustfs_ru v1
abra app secret generate <app-domain> rustfs_rp v1
abra app upgrade <app-domain>
abra app cmd <app-domain> backend migrate_from_minio
abra app config <app-domain> # remove the COMPOSE_FILE line
abra app deploy <app-domain> --force
```
See README "Migrating from MinIO" for details. RustFS uses the new `rustfs_ru`/`rustfs_rp` secrets and `RUSTFS_DOMAIN`. The old `minio_ru`/`minio_rp` secrets and `MINIO_DOMAIN` are only needed during the migration.
**Breaking change:** the Collabora image is distroless and cannot read Docker secrets, so the `collabora_p` secret is no longer used. The admin panel is unusable until you set `COLLABORA_ADMIN_PASSWORD` with `abra app config <app-domain>`. The password is passed as a plain environment variable, visible in `docker service inspect`. Remove `SECRET_COLLABORA_P_VERSION` from your config, and optionally the secret with `abra app secret remove <app-domain> collabora_p`. See README "Configure the Collabora Admin Panel".
Other changes:
- The S3 bucket is now created by the backend on startup (`abra app cmd <app-domain> backend s3_initialize`), the `minio_initialize` command is gone.
- Maintenance commands `clean_pending_items` (daily 0:30) and `purge_deleted_items` (daily 0:45) are now scheduled by celery beat. Configure with `CLEAN_PENDING_ITEMS_SCHEDULE` / `PURGE_DELETED_ITEMS_SCHEDULE`. Note that this permanently purges items deleted from the trash after the upstream grace periods (30 + 7 days).
- New `SDK_ENABLED` setting (default `false`) for the SDK picker used by other La Suite apps.
- The celery workers now get the S3 access key secret too.
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}
+51
View File
@@ -0,0 +1,51 @@
#!/bin/sh
set -e
# Creates the media bucket on the RustFS object storage, with versioning
# enabled. Runs inside the backend container, which ships boto3.
# Load secrets into environment
source /abra-entrypoint.sh -e
python - <<'PYEOF'
import os
import sys
import time
import boto3
from botocore.exceptions import ClientError, EndpointConnectionError
bucket = os.environ.get("AWS_STORAGE_BUCKET_NAME", "drive-media-storage")
s3 = boto3.client(
"s3",
endpoint_url=os.environ["AWS_S3_ENDPOINT_URL"],
aws_access_key_id=os.environ["AWS_S3_ACCESS_KEY_ID"],
aws_secret_access_key=os.environ["AWS_S3_SECRET_ACCESS_KEY"],
region_name=os.environ.get("AWS_S3_REGION_NAME", "us-east-1"),
)
# Wait for the object storage to be ready (up to 60 seconds)
for i in range(60):
try:
s3.list_buckets()
break
except (ClientError, EndpointConnectionError):
time.sleep(1)
else:
sys.exit("s3-initialize: timed out waiting for the object storage")
# Idempotent: skip if bucket already exists
try:
s3.head_bucket(Bucket=bucket)
print(f"s3-initialize: bucket '{bucket}' already exists, skipping")
sys.exit(0)
except ClientError:
pass
print(f"s3-initialize: creating bucket '{bucket}'...")
s3.create_bucket(Bucket=bucket)
s3.put_bucket_versioning(
Bucket=bucket, VersioningConfiguration={"Status": "Enabled"}
)
print("s3-initialize: done")
PYEOF