mirror of
https://github.com/lissy93/portainer-templates.git
synced 2026-11-04 09:15:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62d90253cc | ||
|
|
dda00717aa | ||
|
|
9db920e1d3 | ||
|
|
42546aebab | ||
|
|
1a520db72c | ||
|
|
6d0b490eb0 | ||
|
|
275c4c37aa | ||
|
|
bed63b0d2f | ||
|
|
e225709d4a | ||
|
|
539e9250b9 | ||
|
|
c85ac6edac | ||
|
|
4991945831 | ||
|
|
03c0e5a8dd | ||
|
|
788db83c1c | ||
|
|
e936f93792 | ||
|
|
add79a5321 | ||
|
|
e7843c1a29 | ||
|
|
867b050c80 | ||
|
|
066a42ac73 | ||
|
|
3cdaeb24d2 | ||
|
|
0f69478c1e | ||
|
|
84763f1e24 | ||
|
|
b83d6bb7cd | ||
|
|
4fdd3911ba | ||
|
|
def0ba49a6 | ||
|
|
7d82b0f67f | ||
|
|
2af28edae7 | ||
|
|
ede97aa69f | ||
|
|
08e274e776 | ||
|
|
34c0851aa1 | ||
|
|
5f00408df5 | ||
|
|
15e082290c | ||
|
|
2da1203539 | ||
|
|
c8d1437ad8 | ||
|
|
53042fa4a7 | ||
|
|
03bc08f6fa | ||
|
|
e2e9a60bbb | ||
|
|
1fe0546e17 | ||
|
|
54da95bb1a | ||
|
|
0e9293b02d | ||
|
|
f83f81b7ce | ||
|
|
5cba342491 | ||
|
|
3abb4cdfb8 | ||
|
|
bfc1cf74b7 | ||
|
|
6ce68e005e | ||
|
|
d85cc04ba9 | ||
|
|
b1e716bcc9 | ||
|
|
4fbe2dc26f | ||
|
|
ae30fba285 | ||
|
|
c3e6d550d5 | ||
|
|
8957b7a476 | ||
|
|
133bab4805 | ||
|
|
a14ea54cd2 | ||
|
|
8b26cea163 | ||
|
|
52cc589251 | ||
|
|
e98db73d3f | ||
|
|
f839a40331 | ||
|
|
ae9c628873 | ||
|
|
1d8406bdaa | ||
|
|
2e5cbb711d | ||
|
|
bd5ef0fc6e | ||
|
|
a8077bc0e8 | ||
|
|
07d2928a78 | ||
|
|
9c2749d3fc | ||
|
|
dbd4451c8b | ||
|
|
502429b470 | ||
|
|
3ee4a27e0f | ||
|
|
35d9519344 | ||
|
|
598e99d6ac | ||
|
|
4d1e31786c | ||
|
|
f936887dd1 | ||
|
|
63c49f5ac2 | ||
|
|
2d76612720 | ||
|
|
b82e1fc07e | ||
|
|
fd4f91c5ef | ||
|
|
738cf5bc13 | ||
|
|
264333d03c | ||
|
|
3c5436d350 | ||
|
|
3538716e70 | ||
|
|
a0c780e189 | ||
|
|
55035a4157 | ||
|
|
29cb39f96e | ||
|
|
81e7b5c54a | ||
|
|
4da21f845e | ||
|
|
7b30d9886d | ||
|
|
73401e0e7a | ||
|
|
0f67e544dc | ||
|
|
984281aa5d | ||
|
|
ffa6023191 | ||
|
|
5b3ec8e364 | ||
|
|
7ecb258685 | ||
|
|
5c898d6cdd | ||
|
|
dae9edb827 | ||
|
|
f5045014d5 | ||
|
|
4784f6c42e | ||
|
|
12f5e03798 | ||
|
|
cfe688ab11 | ||
|
|
bb4130d671 | ||
|
|
44ba5f0019 | ||
|
|
f94020cf2b | ||
|
|
cb68891cb6 | ||
|
|
8b817f9c7f | ||
|
|
2ebd40b016 | ||
|
|
028c199dc1 | ||
|
|
88e30843c9 | ||
|
|
80c7228b63 | ||
|
|
058ca78aa0 | ||
|
|
c8d0c773dc | ||
|
|
34c145de73 | ||
|
|
8bc2878cd6 | ||
|
|
ef9d654d70 | ||
|
|
dfbacf94bd | ||
|
|
ff6d12a3ef | ||
|
|
9dd0d203ef | ||
|
|
3162f253f7 | ||
|
|
32f1f2f82e | ||
|
|
360326aa2c | ||
|
|
42fc386a40 | ||
|
|
0aee397e63 | ||
|
|
afa04c5f36 | ||
|
|
9b5e710d4f | ||
|
|
0ff558dcaf | ||
|
|
04b0f4335e | ||
|
|
eadbf41b48 | ||
|
|
6033cfe775 | ||
|
|
cdf3b11390 | ||
|
|
49ebf674eb | ||
|
|
45953bed3b | ||
|
|
560b47d419 | ||
|
|
c86ea59b59 | ||
|
|
a4e9bc8af6 | ||
|
|
df38015f7a | ||
|
|
a016cbecbb | ||
|
|
e5aecabdc2 | ||
|
|
dc4e3ef39a | ||
|
|
9cdfbe7750 | ||
|
|
c910d5b4cc | ||
|
|
f1988ecdad | ||
|
|
ca8fbb36e0 | ||
|
|
d29393913d | ||
|
|
f41ee112d9 | ||
|
|
7d203e7cab | ||
|
|
78870e6139 | ||
|
|
09a5d1e889 | ||
|
|
150959f5db | ||
|
|
ffc252ed99 | ||
|
|
067e454fbc | ||
|
|
d0bca92645 | ||
|
|
2154091189 | ||
|
|
9699bb6b36 | ||
|
|
a512f60246 | ||
|
|
3cd578e5fd | ||
|
|
ad2d07b8a6 | ||
|
|
6c5d0ae9c4 | ||
|
|
1936894c5c | ||
|
|
9960f1efca | ||
|
|
82f525f5a0 | ||
|
|
760b1dc565 | ||
|
|
aa17c6bdb2 | ||
|
|
af3a7a3e48 | ||
|
|
1688e57132 | ||
|
|
3aa08c7a73 | ||
|
|
58491107ff | ||
|
|
d1ed07a405 | ||
|
|
fb756e82df | ||
|
|
4f71eec98b | ||
|
|
a28b626258 | ||
|
|
650df354e7 | ||
|
|
b0b9a115e4 | ||
|
|
f2a9783670 | ||
|
|
55b0a4b57f | ||
|
|
5e37bb60ea | ||
|
|
e24b4aad10 | ||
|
|
a60e3b70cb | ||
|
|
102723be65 | ||
|
|
6a4405789c | ||
|
|
f939b9e56e | ||
|
|
d37bb84870 | ||
|
|
c8989323b9 | ||
|
|
7ecf18b9b7 | ||
|
|
7196ced724 | ||
|
|
1b35191037 | ||
|
|
b66772739f | ||
|
|
d973af5151 | ||
|
|
9ecc013ee3 | ||
|
|
9dc4ad0b4a | ||
|
|
b142631ecd |
No files matched your search
@@ -7,7 +7,7 @@ Sorry it's playing up! But please read this before opening an issue here - it'll
|
||||
### Where to report it
|
||||
|
||||
- **A bug in the app itself** - report it to that **app's own project**.
|
||||
- **A wrong template** (dead image, bad port, missing variable) - report it to the **source it came from**. Every app in the [README](https://github.com/lissy93/portainer-templates#supported-apps-and-stacks) has a **"Report issues"** link beside it, pointing straight at its source.
|
||||
- **A wrong template** (dead image, bad port, missing variable) - report it to the **source it came from**. Every app in the [README](https://github.com/lissy93/portainer-templates#supported-apps-and-stacks) has a **"Report issues"** link beside it, pointing straight at its source. For the growing number of apps whose own authors publish their template, that link goes directly to them, and their fix reaches us the next day.
|
||||
|
||||
Once a fix lands upstream, it flows into our list automatically on the next build.
|
||||
|
||||
|
||||
+72
-6
@@ -4,16 +4,82 @@ Thanks for wanting to help out - it's genuinely appreciated! 🙌
|
||||
|
||||
This repo compiles Portainer app templates from lots of [sources](../sources.csv) into a single `templates.json`. Most contributions are either adding apps to that list, or improving the tooling around it.
|
||||
|
||||
## Adding apps or stacks
|
||||
## Adding your app
|
||||
|
||||
- **Maintain your own template list?** Add its name and raw URL to [`sources.csv`](../sources.csv), and the next build will pull it in.
|
||||
- **Just have a template or two?** Drop a JSON file into [`sources/local/`](../sources/local). It needs to match [Portainer's template format](https://docs.portainer.io/advanced/app-templates/format) - there's a [`Schema.json`](../Schema.json) you can check against.
|
||||
- **Adding a docker-compose stack?** Put the compose file in [`sources/stacks/`](../sources/stacks) and point your template at it.
|
||||
> [!TIP]
|
||||
> **📖 New to Portainer templates? Follow the full guide: [Creating a Template](CREATING_A_TEMPLATE.md)**<br>
|
||||
> It walks through every field with examples, how to test your template, and a checklist to go through before you submit.
|
||||
|
||||
More detail on all of this is in the [Editing](README.md#editing) section of the README.
|
||||
> [!TIP]
|
||||
> Templates added into any of our sources will be automatically pulled into this repo nightly.
|
||||
> For example, you can submit self-hosted apps to [portainer-templates-extras](https://github.com/notAFlightRisk/portainer-templates-extras), and they'll be included in this repo once merged.
|
||||
|
||||
The easiest way is to keep the template in your own repo. We pull it in every day, so you can update it whenever you like, without needing another PR here.
|
||||
|
||||
1. Add a `portainer-template.json` file to your repo, containing a single template
|
||||
2. Add a row to the bottom of [`sources.csv`](../sources.csv) with a name, the raw URL, your repo URL, and `app`
|
||||
|
||||
```csv
|
||||
my_app, https://raw.githubusercontent.com/you/my-app/main/portainer-template.json, https://github.com/you/my-app/, app
|
||||
```
|
||||
|
||||
Your template needs a `type`, `title`, `description`, and either an `image` (for a container) or a `repository` pointing at a compose file (for a stack). It's also worth including a `logo`, `categories`, `platform`, `restart_policy` and a short `note` on getting started, plus whatever `ports`, `volumes` and `env` your app needs. The full format is in [Portainer's docs](https://docs.portainer.io/advanced/app-templates/format), but here's an example:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 1,
|
||||
"title": "My App",
|
||||
"description": "What it does, in a sentence or two.",
|
||||
"categories": ["Productivity"],
|
||||
"platform": "linux",
|
||||
"logo": "https://raw.githubusercontent.com/you/my-app/main/logo.png",
|
||||
"image": "ghcr.io/you/my-app:latest",
|
||||
"restart_policy": "unless-stopped",
|
||||
"ports": ["8080:8080/tcp"],
|
||||
"volumes": [{ "container": "/app/data" }],
|
||||
"env": [{ "name": "TZ", "label": "Timezone", "default": "UTC" }],
|
||||
"note": "Anything people need to know to get it running."
|
||||
}
|
||||
```
|
||||
|
||||
Rather not host it yourself? You can drop a JSON file into [`sources/local/`](../sources/local) instead (and any compose file into [`sources/stacks/`](../sources/stacks)), but you'll need a new PR here each time you want to change it.
|
||||
|
||||
## Adding a template list
|
||||
|
||||
If you maintain a list covering lots of apps, add it to [`sources.csv`](../sources.csv) in the same way, but with `collection` at the end instead of `app`.
|
||||
|
||||
## Fixing a broken template
|
||||
|
||||
Most apps appear in several sources, and we publish whichever copy comes from the highest-priority source. Sometimes that copy is the bad one - a dead image, a tag that no longer exists, a missing port. Rather than copying the template into `sources/local/`, add an entry to [`overrides.json`](../overrides.json) and point it at a source that has a good copy:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Jenkins",
|
||||
"prefer": "mikestraney_templates",
|
||||
"reason": "Portainer's copy pins the retired lts-jdk11 tag",
|
||||
"updated": "2026-09-23"
|
||||
}
|
||||
```
|
||||
|
||||
`title` is matched the same way duplicates are deduped, so case and punctuation don't matter. Add `"type": 3` if the app exists as both a container and a stack. `prefer` is a source name exactly as it appears in `sources.csv`.
|
||||
|
||||
If **no** source has a working copy, leave `prefer` out and record what's wrong instead. The note is published alongside `templates.json` so the website can flag it:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Elasticsearch",
|
||||
"status": "broken",
|
||||
"note": "No source has a working image - upstream publishes no rolling latest tag.",
|
||||
"updated": "2026-09-23"
|
||||
}
|
||||
```
|
||||
|
||||
`status` is one of `ok`, `unmaintained` or `broken`. As a last resort you can write the template out into [`sources/local/`](../sources/local) yourself, since local sources beat every external one - but only when nothing upstream works, because anything in there becomes ours to maintain.
|
||||
|
||||
The build warns when an override stops doing anything - the app disappeared, the preferred source no longer carries it, or normal priority already picks it - so stale entries are easy to spot and delete.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If something's broken in one of the **apps themselves** (a bad image, wrong port, etc.), please raise it with that project upstream, not here. This repo only compiles the templates - once upstream fixes it, it'll flow through on the next build.
|
||||
> If the **app itself** is broken - a bug in the software rather than the template - please raise it with that project upstream. This repo only compiles templates, so an upstream fix flows through on the next build either way. But if **our published template** picks a bad copy of a working app, that one's on us: use `overrides.json` as above.
|
||||
|
||||
## Improving the tooling, docs or site
|
||||
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
# Creating a Portainer template
|
||||
|
||||
This guide walks through writing a Portainer app template for your own app, so people can deploy it from Portainer's template list in a couple of clicks. You'll end up with a single `portainer-template.json` in your repo, which this project pulls in every day.
|
||||
|
||||
In a hurry? Copy one of the [examples](#example-a-container), change the details, then work through the [checklist](#checklist). Already done? Skip to [submitting it](#submitting-it).
|
||||
|
||||
## Contents
|
||||
|
||||
- [Before you start](#before-you-start)
|
||||
- [Choose a type](#choose-a-type)
|
||||
- [Example: a container](#example-a-container)
|
||||
- [Example: a stack](#example-a-stack)
|
||||
- [The fields](#the-fields)
|
||||
- [Getting the format right](#getting-the-format-right)
|
||||
- [Security](#security)
|
||||
- [Testing it](#testing-it)
|
||||
- [Checklist](#checklist)
|
||||
- [Submitting it](#submitting-it)
|
||||
|
||||
## Before you start
|
||||
|
||||
You'll need:
|
||||
|
||||
- A public Docker image of your app, on Docker Hub, GHCR or any registry that doesn't need a login
|
||||
- A public git repo to hold the template file, and your compose file if you're making a stack
|
||||
- The app already running under Docker, so you know its ports, data folders and settings
|
||||
|
||||
## Choose a type
|
||||
|
||||
| Type | What Portainer deploys | Use it when |
|
||||
|------|------------------------|-------------|
|
||||
| `1` | One container, from your image | Your image plus some ports, volumes and env vars is all the app needs |
|
||||
| `3` | A Docker Compose stack, from a compose file in your repo | The app needs other services to run, like a database, cache or worker |
|
||||
|
||||
Go with a container if you can, since it's simpler for people to deploy and tweak. But if the app won't start without a database, make it a stack. Shipping a container and asking users to go and set up Postgres themselves isn't a working template.
|
||||
|
||||
Type `2` (Swarm stack) also exists, but it only deploys to Swarm, so this guide doesn't cover it.
|
||||
|
||||
## Example: a container
|
||||
|
||||
A complete type 1 template, put together from the best ones in this project's list. Copy it and change every value to fit your app.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 1,
|
||||
"title": "My App",
|
||||
"name": "my-app",
|
||||
"description": "Self-hosted bookmark manager with full-text search, tags and browser extensions. Source: https://github.com/you/my-app",
|
||||
"categories": ["Productivity", "Tools"],
|
||||
"platform": "linux",
|
||||
"logo": "https://raw.githubusercontent.com/you/my-app/main/docs/logo.png",
|
||||
"image": "ghcr.io/you/my-app:1",
|
||||
"restart_policy": "unless-stopped",
|
||||
"ports": ["8080:8080/tcp"],
|
||||
"volumes": [{ "container": "/app/data" }],
|
||||
"env": [
|
||||
{
|
||||
"name": "TZ",
|
||||
"label": "Timezone",
|
||||
"description": "Used for timestamps and scheduled jobs, e.g. Europe/London",
|
||||
"default": "UTC"
|
||||
},
|
||||
{
|
||||
"name": "MYAPP_MAX_UPLOAD_MB",
|
||||
"label": "Largest file people can upload, in MB",
|
||||
"default": "50"
|
||||
},
|
||||
{
|
||||
"name": "MYAPP_LOG_LEVEL",
|
||||
"label": "Log level",
|
||||
"select": [
|
||||
{ "text": "Info", "value": "info", "default": true },
|
||||
{ "text": "Debug", "value": "debug" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"note": "On first start My App prints a one-time admin password to the container log. Open <code>http://<your-host>:8080</code> and sign in as <code>admin</code> with it. Everything is stored in the <code>/app/data</code> volume. Docs: <a href=\"https://github.com/you/my-app#readme\" target=\"_blank\">github.com/you/my-app</a>"
|
||||
}
|
||||
```
|
||||
|
||||
Notice that it deploys without anyone touching the form. Every input has a working default, and the app generates its own admin password instead of shipping one.
|
||||
|
||||
## Example: a stack
|
||||
|
||||
A type 3 template points at a compose file in your repo, and its env vars fill in the `${VARIABLES}` in that file.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 3,
|
||||
"title": "My App",
|
||||
"name": "my-app",
|
||||
"description": "Self-hosted bookmark manager with full-text search, tags and browser extensions. Source: https://github.com/you/my-app",
|
||||
"categories": ["Productivity", "Tools"],
|
||||
"platform": "linux",
|
||||
"logo": "https://raw.githubusercontent.com/you/my-app/main/docs/logo.png",
|
||||
"restart_policy": "unless-stopped",
|
||||
"repository": {
|
||||
"url": "https://github.com/you/my-app",
|
||||
"stackfile": "docker-compose.yml"
|
||||
},
|
||||
"env": [
|
||||
{
|
||||
"name": "MYAPP_DB_PASSWORD",
|
||||
"label": "Database password (required)",
|
||||
"description": "Generate one with: openssl rand -hex 32. Keep it the same when you redeploy, or the app can't reach its database."
|
||||
},
|
||||
{
|
||||
"name": "MYAPP_PUBLIC_URL",
|
||||
"label": "Address you'll open My App at",
|
||||
"description": "Used to build links in emails. It must match the address in your browser, port included.",
|
||||
"default": "http://localhost:8080"
|
||||
},
|
||||
{
|
||||
"name": "MYAPP_PORT",
|
||||
"label": "Host port for the web UI",
|
||||
"default": "8080"
|
||||
}
|
||||
],
|
||||
"note": "Set a <b>database password</b> before you deploy (<code>openssl rand -hex 32</code> makes a good one). The stack won't start without it. Then open <code>http://<your-host>:8080</code> and create your account. Docs: <a href=\"https://github.com/you/my-app#readme\" target=\"_blank\">github.com/you/my-app</a>"
|
||||
}
|
||||
```
|
||||
|
||||
And the `docker-compose.yml` it points at, in the root of your repo:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: ghcr.io/you/my-app:1
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${MYAPP_PORT:-8080}:8080"
|
||||
environment:
|
||||
MYAPP_PUBLIC_URL: ${MYAPP_PUBLIC_URL:-http://localhost:8080}
|
||||
MYAPP_DATABASE_URL: postgres://myapp:${MYAPP_DB_PASSWORD:?Set a database password}@db:5432/myapp
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: myapp
|
||||
POSTGRES_USER: myapp
|
||||
POSTGRES_PASSWORD: ${MYAPP_DB_PASSWORD:?Set a database password}
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U myapp -d myapp"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
app-data:
|
||||
db-data:
|
||||
```
|
||||
|
||||
Ideally this is the same compose file your README already tells people to use, so there's only one to maintain. For real stacks published this way, see [Homedex](https://github.com/HarshShah0203/homedex/blob/main/portainer-template.json) and [ReplayHaven](https://github.com/SauerExe/ReplayHaven/blob/main/portainer-template.json).
|
||||
|
||||
### Writing the compose file
|
||||
|
||||
- Portainer clones your repo's default branch and deploys the file at `stackfile`, a path from the repo root. The repo has to be public.
|
||||
- The template's env vars only fill in `${VARIABLES}` in the compose file. They don't reach your containers unless the compose file passes them in, like `MYAPP_PUBLIC_URL` above.
|
||||
- Write optional ones as `${VAR:-default}`, with the same default as the template. A blank field then falls back to the default, and the file still works with a plain `docker compose up`.
|
||||
- Write required ones as `${VAR:?message}`. If someone leaves it blank, the stack fails with your message instead of starting half configured.
|
||||
- Use published images. Don't use `build:`.
|
||||
- Use named volumes. A relative bind mount like `./config:/config` won't see the files in your repo, because by default Portainer resolves it against its own data folder, not a checkout on the host.
|
||||
- Set `restart: unless-stopped` on every service. Portainer ignores the template's `restart_policy` for stacks.
|
||||
- Give the database a healthcheck, and make the app wait for it with `depends_on` and `condition: service_healthy`.
|
||||
- Pin databases to a major version, like `postgres:17-alpine`. Moving to a new major version needs a data migration, so it shouldn't happen during a routine update.
|
||||
- Only publish ports people need to reach. Services talk to each other over the stack's own network, so the database needs no `ports:`.
|
||||
- Leave out `container_name`, which stops anyone deploying the stack twice, and the top-level `version:` key, which Compose no longer uses.
|
||||
|
||||
## The fields
|
||||
|
||||
| Field | Type | Container (1) | Stack (3) |
|
||||
|-------|------|:-------------:|:---------:|
|
||||
| `type` | number | required | required |
|
||||
| `title` | string | required | required |
|
||||
| `description` | string | required | required |
|
||||
| `image` | string | required | |
|
||||
| `repository` | object | | required |
|
||||
| `categories` | list of strings | expected | expected |
|
||||
| `logo` | URL | expected | expected |
|
||||
| `note` | string, basic HTML | expected | expected |
|
||||
| `platform` | `linux` or `windows` | expected | expected |
|
||||
| `restart_policy` | string | expected | expected |
|
||||
| `name` | string | optional | optional |
|
||||
| `env` | list | optional | optional |
|
||||
| `ports` | list of strings | optional | in compose file |
|
||||
| `volumes` | list | optional | in compose file |
|
||||
|
||||
"Expected" means the validator warns if it's missing. Treat those warnings as things to fix.
|
||||
|
||||
### Title and name
|
||||
|
||||
- `title` is your app's name, written the way you brand it, like `Homedex`, `ntfy` or `LibreDB Studio`. No extra words like "Docker" or "Self-hosted", and no version numbers.
|
||||
- If your app is already in the list, use the same title and type. Your template then replaces the community copy, instead of showing up next to it as a duplicate. Case, spaces and punctuation don't matter when matching, so `AdGuard Home` matches `Adguardhome`. Leave off any `(container)` or `(stack)` the website adds to the end. Search [the website](https://portainer-templates.as93.net) to check.
|
||||
- `name` pre-fills the container or stack name on the deploy form. A stack can't be deployed until someone fills that box in, so set it to save people the typing. Portainer only accepts lowercase letters, numbers, `-` and `_`, so something like `my-app` is perfect.
|
||||
|
||||
### Description
|
||||
|
||||
- One or two sentences on what the app does. Portainer shows it in the list next to your logo, so shorter reads better.
|
||||
- Plain text only. Portainer doesn't render Markdown or HTML here, so a `[link](url)` shows up with its brackets.
|
||||
- End it with `Source: https://github.com/you/my-app`, pointing at your repo. That's how people get from the list or the website to your project.
|
||||
- Skip the marketing words like "powerful" or "blazing fast", and just say what it does.
|
||||
|
||||
### Categories
|
||||
|
||||
Pick one to three, most relevant first, and reuse categories that already exist, spelled exactly the same way. Portainer's category filter matches exact names, so if you tag your app `Gaming` while everything similar uses `Games`, it won't show up alongside them.
|
||||
|
||||
Good ones to choose from:
|
||||
|
||||
> AI, Analytics, Automation, Backup, Books, Chat, CMS, Cloud, Dashboard, Database, Development, DNS, Documents, Downloaders, Email, File Sharing, Finance, Games, Home Automation, Media, Messaging, Monitoring, Music, Networking, Photos, Productivity, Project Management, Proxy, Remote Desktop, Security, Social, Storage, Tools, Video, VPN, Web, Wiki
|
||||
|
||||
Avoid `Other`, `Docker` and `Self-hosted`, since they fit every app here and don't help anyone filter. Also avoid `edge`, which Portainer reserves for Edge templates.
|
||||
|
||||
To see every category in use, and how often:
|
||||
|
||||
```bash
|
||||
curl -s https://raw.githubusercontent.com/lissy93/portainer-templates/main/templates.json \
|
||||
| jq -r '.templates[].categories[]?' | sort | uniq -c | sort -rn
|
||||
```
|
||||
|
||||
### Logo
|
||||
|
||||
- Use your app's own logo, so people recognise it. If you don't have one yet, your GitHub avatar is a fine fallback.
|
||||
- Square, with a transparent background, as a PNG or SVG at least 128px across. Portainer shows it small, and the website shows it larger.
|
||||
- Use a stable `https` URL. A file in your repo, linked through `raw.githubusercontent.com` on your default branch, is ideal. Avoid links that expire or might move, like signed CDN links or release assets.
|
||||
- If the URL isn't valid, the logo gets dropped and Portainer shows a generic icon instead.
|
||||
|
||||
### Note
|
||||
|
||||
Portainer shows the note on the deploy form. Use it for whatever people need to get going:
|
||||
|
||||
- where to open the app, and on which port
|
||||
- how the first login works, like a default user, a generated password or a setup page
|
||||
- any required secrets, and how to generate them
|
||||
- where the data is stored
|
||||
- known gotchas, like needing HTTPS or an exact public URL
|
||||
- a link to your docs
|
||||
|
||||
It takes basic HTML such as `<b>`, `<code>`, `<br>` and `<a href="...">`. Portainer strips out images, scripts and inline styles. Markdown doesn't render. To show angle brackets, escape them as `<` and `>`, like the `<your-host>` in the examples.
|
||||
|
||||
### Image
|
||||
|
||||
- Use your official image, the one you build and publish, not a third-party rebuild.
|
||||
- Include the registry in the name, like `ghcr.io/you/my-app:1`. Docker Hub images don't need one, so `you/my-app:1` is fine.
|
||||
- Always set a tag, and pick one you'll keep publishing. A major version tag like `:1` is ideal, because people get fixes without surprise breaking changes. `:latest` works if that's what you publish. A tag for one exact release, like `:1.4.2`, goes stale unless you update the template every release.
|
||||
- Build for `linux/amd64` and `linux/arm64` if you can. Loads of people run Portainer on a Raspberry Pi or another ARM box.
|
||||
- Container templates only. For stacks, images go in the compose file.
|
||||
|
||||
### Ports
|
||||
|
||||
```json
|
||||
"ports": ["8080:8080/tcp", "2222:22/tcp"]
|
||||
```
|
||||
|
||||
- Each one is a string, in the form `host:container/protocol`.
|
||||
- The container side must be the port your app listens on inside the container. The host side is just a default, and people can change it on the deploy form.
|
||||
- Always include the protocol, `/tcp` or `/udp`. Portainer needs it to set up the mapping.
|
||||
- Don't add a host IP, like `127.0.0.1:8080:8080/tcp`. Portainer's template format doesn't support it, and the deploy fails.
|
||||
- Only publish what people need to reach. Metrics and internal ports can stay unpublished.
|
||||
- Avoid 80 and 443 as host defaults, since a reverse proxy usually has them already.
|
||||
|
||||
### Volumes
|
||||
|
||||
```json
|
||||
"volumes": [
|
||||
{ "container": "/app/data" },
|
||||
{ "container": "/media", "bind": "/srv/media", "readonly": true }
|
||||
]
|
||||
```
|
||||
|
||||
- `{ "container": "/app/data" }` makes Portainer create a new Docker volume for that path. Use this by default. It works on any host, and it doesn't need the bind mount permission that admins can take away from regular users.
|
||||
- Add an entry for every path your app writes data to, like its database, config and uploads. Anything left out is lost whenever the container gets recreated, which happens on every update. Caches and temp folders can be skipped.
|
||||
- If you can, keep all of your app's data under one folder, so a single volume covers it.
|
||||
- Only use `bind` when people need to point the app at existing files on the host, like a media library. People can change the path on the deploy form. Add `"readonly": true` if the app only reads those files.
|
||||
|
||||
### Environment variables
|
||||
|
||||
Each entry becomes an input on the deploy form.
|
||||
|
||||
| Key | Type | What it does |
|
||||
|-----|------|--------------|
|
||||
| `name` | string | The variable's name. Required. |
|
||||
| `label` | string | The input's label. Always set it, or people see the raw variable name. |
|
||||
| `description` | string | A tooltip with more detail. |
|
||||
| `default` | string | Pre-fills the input. |
|
||||
| `preset` | boolean | When `true`, the `default` is applied and people can't change it. |
|
||||
| `select` | list | Turns the input into a dropdown. Each option is `{ "text": "...", "value": "..." }`. Mark one with `"default": true`, or the dropdown looks set but sends a blank value. |
|
||||
|
||||
- Only list what most people will want to set on their first deploy, and link to your full list of options in the note.
|
||||
- Give everything a working default, so the app starts without anyone touching the form.
|
||||
- Values are always strings, like `"default": "8080"` and `"default": "true"`. Only `preset` and the `default` inside a `select` option are real booleans.
|
||||
- Portainer passes every variable you list, even blank ones, so make sure your app treats an empty value the same as an unset one.
|
||||
- For stacks, each variable here should appear as a `${VAR}` in your compose file, and each `${VAR}` people should be able to set should be listed here.
|
||||
|
||||
Secrets and passwords need a bit more care:
|
||||
|
||||
- Never ship a real default secret or password. Everyone who deploys the template would end up sharing it.
|
||||
- Best of all, have your app generate what it needs on first start and save it to its data volume. Then the template needs no input.
|
||||
- If people do have to provide one, leave out `default`, put "(required)" in the label, and say how to generate it in the description and the note. `openssl rand -hex 32` is a good suggestion, since hex is safe anywhere, even inside a URL. Portainer won't stop anyone deploying with it blank, so make your app refuse to start without it, or use `${VAR:?message}` in a stack's compose file.
|
||||
- If your app creates an admin account from env vars, don't default the password to `changeme`. Make it required, or print a one-time password to the log on first start.
|
||||
|
||||
### Platform and restart policy
|
||||
|
||||
- `platform` is `linux` for almost every image, and Portainer shows it as an icon. Only use `windows` for Windows container images.
|
||||
- `restart_policy` should be `unless-stopped` for anything long running. Without it Portainer uses `always`, which brings a container you'd stopped back up after a reboot.
|
||||
- For stacks, set `restart:` in the compose file as well.
|
||||
|
||||
### Other fields
|
||||
|
||||
| Field | What it's for |
|
||||
|-------|---------------|
|
||||
| `command` | Overrides the image's default command, as one string. Better to build the right default into your image. |
|
||||
| `privileged` | Gives the container full access to the host. Avoid it unless the app can't work without it. |
|
||||
| `interactive` | Keeps a terminal attached, like `docker run -it`. Rarely needed. |
|
||||
| `administrator_only` | Meant to hide the template from non-admin users, but current Portainer versions ignore it. Setting it to `true` on a template that needs host access does no harm, just don't rely on it. |
|
||||
|
||||
Leave these out:
|
||||
|
||||
- `id`, which the build assigns
|
||||
- `labels`, `network` and `hostname`, since current Portainer versions ignore them when deploying a container template. For a stack, put them in the compose file.
|
||||
- `registry`, since the registry goes in `image`
|
||||
- `stackFile`, since stacks should point at your compose file with `repository`
|
||||
- `maintainer`, which we fill in from `sources.csv`
|
||||
|
||||
## Getting the format right
|
||||
|
||||
- The file is plain JSON. No comments, and no trailing commas.
|
||||
- It holds one template, as a single object like the examples.
|
||||
- Portainer is strict about types. A number or boolean where it expects a string can stop it loading the entire template list, not just yours. Quote everything that's text, including numbers used as text like ports and defaults. Leave `true`, `false` and `type` unquoted.
|
||||
- Spell field names exactly as shown here. Any field the build or Portainer doesn't recognise gets dropped without a warning, so a typo just quietly disappears. It's `restart_policy` not `restartPolicy`, `volumes` not `volume`, and `env` not `environment`.
|
||||
|
||||
## Security
|
||||
|
||||
A template runs with whatever access it asks for, on somebody else's server. Ask for only what the app needs.
|
||||
|
||||
- No secrets anywhere in the template or the compose file.
|
||||
- No default passwords that stay valid after setup. See [secrets and passwords](#environment-variables) above.
|
||||
- Only mount the Docker socket if the app works with containers. Even mounted read-only, the socket gives full control of Docker. If the app only needs to read, put a socket proxy like [`tecnativa/docker-socket-proxy`](https://github.com/Tecnativa/docker-socket-proxy) in front of it, the way [Homedex's compose file](https://github.com/HarshShah0203/homedex/blob/main/docker-compose.yml) does.
|
||||
- Only use `privileged`, host networking, devices or extra capabilities when there's no other way, and explain why in the note.
|
||||
- Portainer admins can block bind mounts, privileged mode and devices for regular users, so a template that needs them may only deploy for admins. Say so in the note.
|
||||
- If the first person to open your setup page becomes the admin, say so in the note, so people finish setup before anyone else can reach it.
|
||||
|
||||
## Testing it
|
||||
|
||||
A template can pass every check and still not run, so test it three ways.
|
||||
|
||||
**1. Check the format.** Clone this repo and run the validator against your file's raw URL:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/lissy93/portainer-templates.git
|
||||
cd portainer-templates
|
||||
python3 -m venv .venv && . .venv/bin/activate
|
||||
make install_requirements
|
||||
python3 lib/validate_sources.py https://raw.githubusercontent.com/you/my-app/main/portainer-template.json
|
||||
```
|
||||
|
||||
Fix every error, and the warnings too. The same check runs automatically on your PR.
|
||||
|
||||
**2. For stacks, check the compose file on its own.** Set any required variables, then run:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml config # prints the file with every variable filled in
|
||||
docker compose -f docker-compose.yml up -d
|
||||
```
|
||||
|
||||
**3. Deploy it from Portainer.** This is the real test, since it's exactly what your users will do. Portainer reads a list of templates, so wrap yours in one and put it somewhere public, like a GitHub Gist:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "3",
|
||||
"templates": [
|
||||
{ "type": 1, "title": "My App", "...": "the rest of your template" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
In Portainer, go to Settings, paste the raw URL into the App Templates URL field and save. Then open your environment's app templates, find your app, and deploy it to a clean host, leaving the form at its defaults apart from any required secrets. Check that:
|
||||
|
||||
- every input on the form has a clear label and a sensible default
|
||||
- it deploys, and the app opens on the expected port. Stacks deploy in the background, so check the stack's status in the Stacks list, not just the success message
|
||||
- you can log in and actually use it
|
||||
- your data survives. Add something, then recreate the container or redeploy the stack while keeping its volumes, and make sure it's still there
|
||||
- it comes back by itself after the host restarts
|
||||
|
||||
When you're done, set the App Templates URL back to what it was, or to this project's list at `https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json`.
|
||||
|
||||
> [!TIP]
|
||||
> Your `portainer-template.json` can use this wrapped format too, as long as it holds just one template. Then you can point Portainer straight at it while testing.
|
||||
|
||||
## Checklist
|
||||
|
||||
Go through this before you submit.
|
||||
|
||||
**It works**
|
||||
|
||||
- [ ] It deploys and starts with the default values. The only fields people must fill in are secrets that can't have a safe default, and those are clearly marked.
|
||||
- [ ] I've deployed it from Portainer itself, on a clean host.
|
||||
- [ ] The container ports match what the app actually listens on.
|
||||
- [ ] Every path the app writes data to has a volume, and the data survives the container being recreated.
|
||||
- [ ] It uses named volumes, with bind mounts only where people need to reach host files.
|
||||
- [ ] Stacks only: the compose file is on the default branch of a public repo, uses published images and named volumes, and has a `${VAR}` for every variable in `env`.
|
||||
|
||||
**It's valid**
|
||||
|
||||
- [ ] `python3 lib/validate_sources.py <your URL>` passes with no errors or warnings.
|
||||
- [ ] The file holds one template, with no `id`.
|
||||
- [ ] Text values are quoted, and booleans aren't.
|
||||
|
||||
**It's unique**
|
||||
|
||||
- [ ] The app isn't already in the list under another name. If it's there already, I've used the same title and type so mine replaces it.
|
||||
- [ ] The title is the app's real name.
|
||||
- [ ] The logo is the app's own, square, at a stable URL.
|
||||
|
||||
**It's described well**
|
||||
|
||||
- [ ] The description is one or two plain text sentences, ending with `Source: https://github.com/you/my-app`.
|
||||
- [ ] It has one to three existing categories.
|
||||
- [ ] The note covers the port, the first login, any required secrets and where the data lives.
|
||||
- [ ] Every env var has a label.
|
||||
|
||||
**It's safe**
|
||||
|
||||
- [ ] It uses the official image, with a tag I keep publishing, built for arm64 as well as amd64 where possible.
|
||||
- [ ] There are no secrets or default passwords in the template or compose file.
|
||||
- [ ] Docker socket access, privileged mode, host networking and devices are only there if the app can't work without them, and the note explains why.
|
||||
|
||||
## Submitting it
|
||||
|
||||
1. Commit `portainer-template.json` to your repo's default branch.
|
||||
2. Add a row to the bottom of [`sources.csv`](../sources.csv), with a name, the raw URL of your file, your repo URL, and `app`:
|
||||
|
||||
```csv
|
||||
my_app, https://raw.githubusercontent.com/you/my-app/main/portainer-template.json, https://github.com/you/my-app/, app
|
||||
```
|
||||
|
||||
The name has to be unique, start with a lowercase letter or number, and only use lowercase letters, numbers, `_` and `-`.
|
||||
3. If your app already has a copy in one of the files in [`sources/local/`](../sources/local), remove that entry in the same PR, along with any entry for it in [`overrides.json`](../overrides.json). Otherwise the old copy keeps winning and your updates never show up.
|
||||
4. Open a PR here.
|
||||
|
||||
Once it's merged, you can edit the template in your own repo whenever you like, and changes show up here within a day. Keep the URL pointing at a branch rather than a tag or a commit.
|
||||
|
||||
Run the validator again after every change. If the file moves, stops being valid JSON, gains a second template or fails the checks, your app quietly drops out of the list until it's fixed, and nobody gets told.
|
||||
|
||||
Each app source holds one template. If you'd like to offer a second version, like a GPU build, publish it as a separate file with its own title, and add another row for it.
|
||||
|
||||
For anything else, like adding a whole list of templates, see the [contributing guide](CONTRIBUTING.md).
|
||||
@@ -4,7 +4,7 @@ So that your PR can be handled quickly, please ensure you've followed .github/CO
|
||||
-->
|
||||
|
||||
### Category
|
||||
<!-- New template / new source / tooling / docs / workflows / security -->
|
||||
<!-- New app source / new template / new collection source / tooling / docs / workflows / security -->
|
||||
|
||||
### Summary
|
||||
<!--
|
||||
@@ -17,3 +17,6 @@ If it relates to an issue, mention the ticket num here (like "closes #420")
|
||||
- [ ] I've read the [contributing guide](.github/CONTRIBUTING.md)
|
||||
- [ ] I've self-reviewed my changes to ensure they are valid
|
||||
- [ ] I'm not modifying `templates.json` directly (it's auto-generated)
|
||||
<!-- Only if you're registering your own app in sources.csv with kind `app`: -->
|
||||
- [ ] My template URL points at a branch (not a tag or commit), so updates flow through
|
||||
- [ ] I've removed my app's old copy from `sources/local/`, if it had one
|
||||
+927
-696
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,7 @@ on:
|
||||
paths:
|
||||
- 'sources/**'
|
||||
- 'sources.csv'
|
||||
- 'overrides.json'
|
||||
|
||||
# Read by default; the build job elevates to write to commit
|
||||
permissions:
|
||||
@@ -41,7 +42,7 @@ jobs:
|
||||
|
||||
# Downloads + installs Python (used for running gen scripts)
|
||||
- name: Set up Python 🐍
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python 🐍
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
if [ -f .github/changelog.json ]; then
|
||||
versions=$(jq '.entries | length' .github/changelog.json)
|
||||
latest=$(jq -r '.entries[0]
|
||||
| "\(.version): ✨ \(.added | length) added · 🗑️ \(.removed | length) removed · ♻️ \(.updated | length) updated"
|
||||
| "\(.version): ✨ \(.added | length) added · 🗑️ \(.removed | length) removed · 🏷️ \(.renamed | length) renamed · ♻️ \(.updated | length) updated"
|
||||
' .github/changelog.json)
|
||||
echo "| Versions | ${versions} |"
|
||||
echo "| Latest | ${latest} |"
|
||||
|
||||
@@ -46,6 +46,7 @@ jobs:
|
||||
- 'sources/stacks/**'
|
||||
sources_csv:
|
||||
- 'sources.csv'
|
||||
- 'overrides.json'
|
||||
scripts:
|
||||
- 'lib/**'
|
||||
- 'Schema.json'
|
||||
@@ -67,7 +68,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.x'
|
||||
cache: pip
|
||||
@@ -93,7 +94,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.x'
|
||||
cache: pip
|
||||
@@ -105,7 +106,7 @@ jobs:
|
||||
- name: Validate compose stack files
|
||||
run: python lib/validate_sources.py sources/stacks
|
||||
|
||||
# Validate the external sources list (structure + unique names + http urls)
|
||||
# Validate the external sources list + the per-template overrides that pin against it
|
||||
sources-list:
|
||||
name: 📇 Sources List
|
||||
runs-on: ubuntu-latest
|
||||
@@ -117,9 +118,11 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Deep enough to diff sources.csv against the PR's base
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.x'
|
||||
cache: pip
|
||||
@@ -128,8 +131,31 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pip install -r lib/requirements.txt
|
||||
|
||||
- name: Validate sources.csv
|
||||
run: python lib/validate_sources.py sources.csv
|
||||
- name: Validate sources.csv + overrides.json
|
||||
run: python lib/validate_sources.py sources.csv overrides.json
|
||||
|
||||
# Check source urls this PR changes: a collection the build can't read stops the nightly
|
||||
- name: Validate newly added sources
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# '|| true' since grep exits 1 when this PR only touches overrides.json
|
||||
urls=$(git diff "$BASE_SHA" -- sources.csv \
|
||||
| grep '^+[^+]' | cut -c2- \
|
||||
| awk -F, '{ gsub(/[[:blank:]]/, "", $2)
|
||||
if ($2 ~ /^https?:\/\//) print $2 }' || true)
|
||||
if [ -z "$urls" ]; then
|
||||
echo "This PR changes no source urls, nothing to fetch."
|
||||
exit 0
|
||||
fi
|
||||
status=0
|
||||
for url in $urls; do
|
||||
echo "--- Validating $url"
|
||||
python lib/validate_sources.py "$url" || status=1
|
||||
done
|
||||
exit $status
|
||||
|
||||
# When the tooling or schema changes: compile, self-check the schema, re-validate output
|
||||
scripts:
|
||||
@@ -145,7 +171,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.x'
|
||||
cache: pip
|
||||
@@ -191,7 +217,7 @@ jobs:
|
||||
fail-on-error: true
|
||||
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
|
||||
with:
|
||||
# scan the whole workflows dir
|
||||
inputs: .github/workflows/
|
||||
@@ -211,7 +237,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.x'
|
||||
cache: pip
|
||||
@@ -231,7 +257,7 @@ jobs:
|
||||
run: make combine validate
|
||||
|
||||
- name: Upload templates.json
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: templates-json
|
||||
path: templates.json
|
||||
|
||||
@@ -55,19 +55,19 @@ jobs:
|
||||
org.opencontainers.image.licenses=MIT
|
||||
|
||||
- name: 🔧 Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
||||
|
||||
- name: 🔧 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||
|
||||
- name: 🔑 Login to DockerHub
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: 🔑 Login to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
uses: crazy-max/ghaction-docker-status@8924b6134452fab7da5c3fdf17f5c4befd1fc244 # v4
|
||||
|
||||
- name: ⚒️ Build and push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
@@ -39,6 +39,11 @@ jobs:
|
||||
# We authenticate gh with an explicit token below
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python 🐍
|
||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Resolve tags
|
||||
id: refs
|
||||
env:
|
||||
@@ -89,16 +94,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
git show "${TARGET}:templates.json" > new.json
|
||||
|
||||
# templates.json at the previous release (empty for a first release)
|
||||
if [ -n "$PREV" ] && git cat-file -e "${PREV}:templates.json" 2>/dev/null; then
|
||||
git show "${PREV}:templates.json" > old.json
|
||||
else
|
||||
if [ -n "$PREV" ]; then
|
||||
echo "::warning::templates.json not found at ${PREV}; treating as first release"
|
||||
fi
|
||||
echo '{"templates":[]}' > old.json
|
||||
fi
|
||||
total=$(jq '.templates | length' new.json)
|
||||
|
||||
# Release title, e.g. "Portainer Templates v0.11.0 (June 2026)". Month comes
|
||||
@@ -108,20 +103,12 @@ jobs:
|
||||
[ -n "$month" ] || month=$(date -u '+%B %Y')
|
||||
title="Portainer Templates ${TARGET} (${month})"
|
||||
|
||||
# Key by title, minus the `id` (it renumbers each regen -> false "updated")
|
||||
jq -S '.templates | map({(.title): (del(.id))}) | add // {}' new.json > new_map.json
|
||||
jq -S '.templates | map({(.title): (del(.id))}) | add // {}' old.json > old_map.json
|
||||
|
||||
jq -n --slurpfile n new_map.json --slurpfile o old_map.json '
|
||||
$n[0] as $n | $o[0] as $o |
|
||||
{
|
||||
added: (($n | keys) - ($o | keys)),
|
||||
removed: (($o | keys) - ($n | keys)),
|
||||
updated: [ $n | keys[] as $k | select( ($o | has($k)) and ($n[$k] != $o[$k]) ) | $k ]
|
||||
}' > diff.json
|
||||
# Shares the changelog's diff, so a rename isn't a removal plus an addition
|
||||
python3 lib/changelog.py --diff "$PREV" "$TARGET" > diff.json
|
||||
|
||||
added=$(jq '.added | length' diff.json)
|
||||
removed=$(jq '.removed | length' diff.json)
|
||||
renamed=$(jq '.renamed | length' diff.json)
|
||||
updated=$(jq '.updated | length' diff.json)
|
||||
|
||||
# Render the release notes (lists capped inside jq)
|
||||
@@ -137,11 +124,12 @@ jobs:
|
||||
def slugify: ascii_downcase | gsub("[^a-z ]"; "") | gsub(" "; "-");
|
||||
def md_esc: gsub("(?<x>[\\[\\]])"; "\\\(.x)");
|
||||
def item(link): if link then "- [\(md_esc)](\($site)/\(. | slugify))" else "- \(.)" end;
|
||||
def section(title; arr; cap; link):
|
||||
def renamed_item: "- [\(.to | md_esc)](\($site)/\(.to | slugify)) (was \(.from | md_esc))";
|
||||
def section(title; arr; cap; render):
|
||||
if (arr | length) == 0 then empty
|
||||
else
|
||||
"### \(title) (\(arr | length))",
|
||||
(arr[0:cap][] | item(link)),
|
||||
(arr[0:cap][] | render),
|
||||
(if (arr | length) > cap
|
||||
then "- …and \((arr | length) - cap) more"
|
||||
else empty end),
|
||||
@@ -151,9 +139,10 @@ jobs:
|
||||
( if $prev == "" then empty
|
||||
else
|
||||
"",
|
||||
section("➕ Added"; $d.added; 40; true),
|
||||
section("➖ Removed"; $d.removed; 40; false),
|
||||
section("🔄 Updated"; $d.updated; 40; true),
|
||||
section("➕ Added"; $d.added; 40; item(true)),
|
||||
section("➖ Removed"; $d.removed; 40; item(false)),
|
||||
section("🏷️ Renamed"; $d.renamed; 40; renamed_item),
|
||||
section("🔄 Updated"; $d.updated; 40; item(true)),
|
||||
"[Full diff](\($repo)/compare/\($prev)...\($cur))"
|
||||
end )
|
||||
' > notes.md
|
||||
@@ -161,11 +150,11 @@ jobs:
|
||||
# The tag's templates.json becomes the release asset
|
||||
cp new.json templates.json
|
||||
|
||||
echo "Changelog: +${added} added, -${removed} removed, ~${updated} updated (${total} total)"
|
||||
echo "Changelog: +${added} added, -${removed} removed, ${renamed} renamed, ~${updated} updated (${total} total)"
|
||||
|
||||
# Skip publishing if nothing changed -- unless it's the first release
|
||||
# (no previous), which is always worth cutting.
|
||||
if [ -n "$PREV" ] && [ "$((added + removed + updated))" -eq 0 ]; then
|
||||
if [ -n "$PREV" ] && [ "$((added + removed + renamed + updated))" -eq 0 ]; then
|
||||
changed=false
|
||||
echo "::notice::No template changes since ${PREV}; skipping the release"
|
||||
else
|
||||
@@ -176,6 +165,7 @@ jobs:
|
||||
echo "total=${total}"
|
||||
echo "added=${added}"
|
||||
echo "removed=${removed}"
|
||||
echo "renamed=${renamed}"
|
||||
echo "updated=${updated}"
|
||||
echo "changed=${changed}"
|
||||
echo "title=${title}"
|
||||
@@ -216,6 +206,7 @@ jobs:
|
||||
TOTAL: ${{ steps.notes.outputs.total }}
|
||||
ADDED: ${{ steps.notes.outputs.added }}
|
||||
REMOVED: ${{ steps.notes.outputs.removed }}
|
||||
RENAMED: ${{ steps.notes.outputs.renamed }}
|
||||
UPDATED: ${{ steps.notes.outputs.updated }}
|
||||
CHANGED: ${{ steps.notes.outputs.changed }}
|
||||
RESULT: ${{ steps.publish.outputs.result }}
|
||||
@@ -239,5 +230,5 @@ jobs:
|
||||
fi
|
||||
echo "| Previous | \`${PREV:-none}\` |"
|
||||
echo "| Templates | ${TOTAL:-n/a} total |"
|
||||
echo "| Changes | ✨ ${ADDED:-0} added · 🗑️ ${REMOVED:-0} removed · ♻️ ${UPDATED:-0} updated |"
|
||||
echo "| Changes | ✨ ${ADDED:-0} added · 🗑️ ${REMOVED:-0} removed · 🏷️ ${RENAMED:-0} renamed · ♻️ ${UPDATED:-0} updated |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,6 +1,8 @@
|
||||
FROM nginx:stable-alpine
|
||||
|
||||
COPY templates.json /usr/share/nginx/html/templates.json
|
||||
# Served alongside the templates so the site can flag known-broken apps
|
||||
COPY overrides.json /usr/share/nginx/html/overrides.json
|
||||
COPY index.html /usr/share/nginx/html/index.html
|
||||
|
||||
EXPOSE 80
|
||||
+94
-11
@@ -2,12 +2,13 @@
|
||||
|
||||
Walks every vMAJOR.MINOR.PATCH tag in version order, diffs the compiled
|
||||
templates.json between consecutive tags, and records the templates added,
|
||||
removed and updated in each version. Rebuilt from scratch on every run, so
|
||||
the output is deterministic (safe to re-run, self-heals, no commit churn).
|
||||
removed, renamed and updated in each version. Rebuilt from scratch on every
|
||||
run, so the output is deterministic (safe to re-run, self-heals, no churn).
|
||||
|
||||
Matches the diff semantics of the release notes in release.yml: templates
|
||||
are keyed by title, with `id` ignored (it renumbers on every regeneration,
|
||||
which would otherwise flag everything as updated).
|
||||
`--diff <old-tag> <new-tag>` instead prints a single diff as JSON, which is
|
||||
how release.yml builds its release notes, so both share these semantics:
|
||||
templates are keyed by title, with `id` ignored (it renumbers on every
|
||||
regeneration, which would otherwise flag everything as updated).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@@ -61,17 +62,91 @@ def templates_at(tag):
|
||||
return {t['title']: {k: v for k, v in t.items() if k != 'id'}
|
||||
for t in templates if isinstance(t, dict) and 'title' in t}
|
||||
|
||||
def by_name(template):
|
||||
"""Bare letters and digits of a title, so 'Node Red' and 'Node-Red' match"""
|
||||
return re.sub(r'[^a-z0-9]', '', template['title'].lower())
|
||||
|
||||
def by_body(template):
|
||||
"""Every field but the title, so a template under a new name still matches"""
|
||||
return json.dumps({k: v for k, v in template.items() if k != 'title'}, sort_keys=True)
|
||||
|
||||
def match_renames(old, new, added, removed):
|
||||
"""Renames, matched on title then body and dropped from `added` and `removed`.
|
||||
A pair only counts when exactly one candidate matches, so splits stay honest."""
|
||||
renamed = []
|
||||
for key in (by_name, by_body):
|
||||
candidates = {}
|
||||
for title in added:
|
||||
candidates.setdefault(key(new[title]), []).append(title)
|
||||
for title in list(removed):
|
||||
identity = key(old[title])
|
||||
if len(candidates.get(identity, [])) == 1:
|
||||
new_title = candidates.pop(identity)[0]
|
||||
renamed.append({'from': title, 'to': new_title})
|
||||
added.remove(new_title)
|
||||
removed.remove(title)
|
||||
return sorted(renamed, key=lambda pair: pair['from'])
|
||||
|
||||
def diff(old, new):
|
||||
"""Added / removed / updated templates between two title-keyed maps"""
|
||||
"""Added / removed / renamed / updated templates between two title-keyed maps"""
|
||||
added = sorted(new.keys() - old.keys())
|
||||
removed = sorted(old.keys() - new.keys())
|
||||
renamed = match_renames(old, new, added, removed)
|
||||
updated = []
|
||||
for title in sorted(new.keys() & old.keys()):
|
||||
if new[title] != old[title]:
|
||||
fields = sorted(k for k in new[title].keys() | old[title].keys()
|
||||
if new[title].get(k) != old[title].get(k))
|
||||
updated.append({'title': title, 'fields': fields})
|
||||
return added, removed, updated
|
||||
return added, removed, renamed, updated
|
||||
|
||||
def renames_between(old_tag, previous, new_tag):
|
||||
"""Every rename step from old_tag to new_tag, one tag at a time. A release spans
|
||||
several tags, and a title renamed twice only matches up when each hop is paired."""
|
||||
chain = {}
|
||||
for tag in semver_tags():
|
||||
if not version(old_tag) < version(tag) <= version(new_tag):
|
||||
continue
|
||||
current = templates_at(tag)
|
||||
if current is None:
|
||||
continue
|
||||
added = sorted(current.keys() - previous.keys())
|
||||
removed = sorted(previous.keys() - current.keys())
|
||||
for pair in match_renames(previous, current, added, removed):
|
||||
chain[pair['from']] = pair['to']
|
||||
previous = current
|
||||
return chain
|
||||
|
||||
def final_title(chain, title):
|
||||
"""The name a title ends up under once its renames are followed, loops aside"""
|
||||
seen = {title}
|
||||
while title in chain and chain[title] not in seen:
|
||||
title = chain[title]
|
||||
seen.add(title)
|
||||
return title
|
||||
|
||||
def print_diff(old_tag, new_tag):
|
||||
"""Print one tag-to-tag diff as JSON, for the release notes in release.yml.
|
||||
An empty old_tag, or one with no templates.json, counts as a first release."""
|
||||
new = templates_at(new_tag)
|
||||
if new is None:
|
||||
log.error(f'No templates.json at {new_tag}, nothing to diff')
|
||||
sys.exit(1)
|
||||
old = templates_at(old_tag) if old_tag else {}
|
||||
if old is None:
|
||||
log.warning(f'No templates.json at {old_tag}, treating as a first release')
|
||||
old = {}
|
||||
added, removed, renamed, updated = diff(old, new)
|
||||
chain = renames_between(old_tag, old, new_tag) if old else {}
|
||||
for title in list(removed):
|
||||
landed = final_title(chain, title)
|
||||
if landed in added:
|
||||
renamed.append({'from': title, 'to': landed})
|
||||
added.remove(landed)
|
||||
removed.remove(title)
|
||||
print(json.dumps({'added': added, 'removed': removed,
|
||||
'renamed': sorted(renamed, key=lambda pair: pair['from']),
|
||||
'updated': [item['title'] for item in updated]}))
|
||||
|
||||
def main():
|
||||
banner('Changelog', 'Diff templates.json between version tags')
|
||||
@@ -89,17 +164,19 @@ def main():
|
||||
log.warning(f'{tag}: no templates.json at this tag, skipping')
|
||||
continue
|
||||
first = previous_map is None
|
||||
added, removed, updated = ([], [], []) if first else diff(previous_map, current)
|
||||
added, removed, renamed, updated = \
|
||||
([], [], [], []) if first else diff(previous_map, current)
|
||||
entries.append({
|
||||
'version': tag,
|
||||
'previous': previous_tag,
|
||||
'date': dates.get(tag),
|
||||
'templateCount': len(current),
|
||||
'added': added, 'removed': removed, 'updated': updated,
|
||||
'added': added, 'removed': removed,
|
||||
'renamed': renamed, 'updated': updated,
|
||||
})
|
||||
log.info(f'{tag}: first version ({len(current)} total)' if first else
|
||||
f'{tag}: +{len(added)} added, -{len(removed)} removed, '
|
||||
f'~{len(updated)} updated ({len(current)} total)')
|
||||
f'~{len(updated)} updated, {len(renamed)} renamed ({len(current)} total)')
|
||||
previous_tag, previous_map = tag, current
|
||||
|
||||
entries.reverse() # newest first
|
||||
@@ -109,4 +186,10 @@ def main():
|
||||
log.info(f'Wrote {len(entries)} versions to {os.path.relpath(output_path, root_dir)}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
if sys.argv[1:2] == ['--diff']:
|
||||
if len(sys.argv) != 4:
|
||||
log.error('Usage: changelog.py --diff <old-tag> <new-tag>')
|
||||
sys.exit(1)
|
||||
print_diff(sys.argv[2], sys.argv[3])
|
||||
else:
|
||||
main()
|
||||
+174
-34
@@ -1,12 +1,13 @@
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import string
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
import jsonschema
|
||||
|
||||
import sources_list
|
||||
from log import get_logger, banner
|
||||
|
||||
log = get_logger()
|
||||
@@ -18,6 +19,8 @@ with open(os.path.join(BASE_DIR, 'Schema.json')) as f:
|
||||
SCHEMA = json.load(f)
|
||||
FORMAT_CHECKER = jsonschema.FormatChecker()
|
||||
TEMPLATE_KEYS = set(SCHEMA['properties']['templates']['items']['properties'])
|
||||
ITEM_VALIDATOR = jsonschema.Draft7Validator(SCHEMA['properties']['templates']['items'],
|
||||
format_checker=FORMAT_CHECKER)
|
||||
|
||||
TYPE_LABELS = {1: 'container', 2: 'swarm', 3: 'stack', 4: 'edge'}
|
||||
|
||||
@@ -32,6 +35,8 @@ def normalize_category(c):
|
||||
"""Split a category into words and title-case it for display"""
|
||||
if not isinstance(c, str):
|
||||
return ''
|
||||
# Some sources namespace their categories, and the subtype is the useful half
|
||||
c = c.rsplit(':', 1)[-1].strip() or c
|
||||
words = [w for w in re.split(r'[^0-9a-zA-Z]+', c) if w]
|
||||
label = ' '.join(w.upper() if w.lower() in CATEGORY_ACRONYMS else w.capitalize()
|
||||
for w in words)
|
||||
@@ -45,8 +50,38 @@ def template_score(t):
|
||||
score += len(t.get('ports', []))
|
||||
return score
|
||||
|
||||
def load_sources():
|
||||
def template_rank(t, pinned=None):
|
||||
"""Sort key for choosing between duplicates, higher wins: pinned source, then source
|
||||
priority, then completeness."""
|
||||
return (t['_source'] == pinned, -t['_priority'], template_score(t))
|
||||
|
||||
def source_priority(sources):
|
||||
"""File name -> dedup priority, lower wins: app sources first, then sources.csv order"""
|
||||
ordered = [s for s in sources if s.is_app] + [s for s in sources if not s.is_app]
|
||||
return {s.filename: i for i, s in enumerate(ordered, start=1)}
|
||||
|
||||
def load_overrides():
|
||||
"""Per-template fixes from overrides.json, keyed by (normalized title, type)."""
|
||||
path = os.path.join(BASE_DIR, 'overrides.json')
|
||||
if not os.path.isfile(path):
|
||||
return {}
|
||||
with open(path) as f:
|
||||
try:
|
||||
entries = json.load(f).get('overrides', [])
|
||||
except json.decoder.JSONDecodeError as err:
|
||||
log.error(f'Refusing to build: overrides.json is not valid JSON ({err})')
|
||||
sys.exit(1)
|
||||
return {(normalize_string(e['title']), e.get('type', 1)): e for e in entries
|
||||
if isinstance(e, dict) and isinstance(e.get('title'), str) and e['title'].strip()}
|
||||
|
||||
def pinned_source(overrides, key):
|
||||
"""The source file name an override pins this (title, type) to, if any."""
|
||||
prefer = overrides.get(key, {}).get('prefer')
|
||||
return f'{prefer}.json' if prefer else None
|
||||
|
||||
def load_sources(sources):
|
||||
"""Load and merge all template JSON files from sources/local/ and sources/external/."""
|
||||
rank = source_priority(sources)
|
||||
templates = []
|
||||
local_dir = os.path.join(SOURCES_DIR, 'local')
|
||||
external_dir = os.path.join(SOURCES_DIR, 'external')
|
||||
@@ -64,9 +99,11 @@ def load_sources():
|
||||
log.warning(f'Skipping source due to error: {f.name} ({err})')
|
||||
continue
|
||||
source_templates = [t for t in source_templates if isinstance(t, dict)]
|
||||
# Dedup priority: local files first, then external sources in sources.csv order
|
||||
priority = 0 if is_local else rank.get(file, len(rank) + 1)
|
||||
for t in source_templates:
|
||||
t['_source'] = file
|
||||
t['_local'] = is_local
|
||||
t['_priority'] = priority
|
||||
templates += source_templates
|
||||
return templates
|
||||
|
||||
@@ -154,6 +191,11 @@ def normalize_template(t):
|
||||
t['ports'] = []
|
||||
t['ports'] = [s for s in (str(p).lstrip(':') for p in t['ports']) if s]
|
||||
|
||||
# An untagged image resolves to ':latest', so make that explicit
|
||||
image = t.get('image')
|
||||
if isinstance(image, str) and image and ':' not in image.split('/')[-1]:
|
||||
t['image'] = f'{image}:latest'
|
||||
|
||||
if isinstance(t.get('maintainer'), str):
|
||||
t['maintainer'] = t['maintainer'].strip()
|
||||
|
||||
@@ -187,7 +229,7 @@ def normalize_template(t):
|
||||
if 'volumes' in t:
|
||||
t['volumes'] = cleaned_volumes
|
||||
|
||||
# Drop fields outside the schema ('_source'/'_local' tags are kept for dedup, stripped later)
|
||||
# Drop fields outside the schema ('_source'/'_priority' tags are kept for dedup, stripped later)
|
||||
for k in list(t):
|
||||
if k not in TEMPLATE_KEYS and not k.startswith('_'):
|
||||
del t[k]
|
||||
@@ -203,6 +245,12 @@ def normalize_template_fields(templates):
|
||||
log.warning(f'Skipping unnormalizable template: {t.get("title", "<no title>")} ({err})')
|
||||
return normalized
|
||||
|
||||
def schema_errors(t):
|
||||
"""Schema violations in one template, so a bad entry is dropped instead of failing the build"""
|
||||
probe = {k: v for k, v in t.items() if not k.startswith('_')}
|
||||
probe.setdefault('id', 1) # combine assigns real ids at write time
|
||||
return [e.message for e in ITEM_VALIDATOR.iter_errors(probe)]
|
||||
|
||||
def is_valid_template(t):
|
||||
"""Check a template has the required fields for its type."""
|
||||
if not (isinstance(t.get('title'), str) and t['title'].strip()):
|
||||
@@ -220,28 +268,54 @@ def is_valid_template(t):
|
||||
return False
|
||||
return True
|
||||
|
||||
def deduplicate_and_normalize(templates):
|
||||
"""Filter invalid, deduplicate by (title, type) keeping best version, and normalize category names."""
|
||||
GITHUB_LINK = re.compile(r'\bgithub\.com/([\w.-]+)/([\w.-]+)', re.I)
|
||||
# Owners and monorepos that republish other people's apps, so aren't the app itself
|
||||
NOT_THE_APP = {'sponsors', 'orgs', 'apps', 'topics', 'about', 'features', 'marketplace',
|
||||
'linuxserver', 'pi-hosted/pi-hosted'}
|
||||
|
||||
def app_repo(t):
|
||||
"""The app's own repo: the first github link in its text, else its GHCR namespace."""
|
||||
image = str(t.get('image') or '').split('@')[0].split(':')[0].split('/')
|
||||
match = GITHUB_LINK.search(f'{t.get("description") or ""} {t.get("note") or ""}')
|
||||
found = [match.groups()] if match else []
|
||||
if len(image) == 3 and image[0] == 'ghcr.io':
|
||||
found.append(tuple(image[1:]))
|
||||
for owner, repo in found:
|
||||
if repo and not {owner.lower(), f'{owner}/{repo}'.lower()} & NOT_THE_APP:
|
||||
return f'{owner}/{repo}'
|
||||
return None
|
||||
|
||||
def backfill_app_source(templates, candidates):
|
||||
"""Re-attach the app's repo where dedup or the container/stack split lost it."""
|
||||
known = {}
|
||||
for t in sorted(candidates, key=lambda t: t.get('_priority', 0)):
|
||||
repo = app_repo(t)
|
||||
if repo and isinstance(t.get('title'), str):
|
||||
known.setdefault(normalize_string(t['title']), repo)
|
||||
filled = 0
|
||||
for t in templates:
|
||||
repo = known.get(normalize_string(t['title']))
|
||||
if repo and not app_repo(t):
|
||||
t['description'] = f'{t["description"].rstrip()}\n\nSource: https://github.com/{repo}'
|
||||
filled += 1
|
||||
return filled
|
||||
|
||||
def deduplicate_and_normalize(templates, overrides=None):
|
||||
"""Filter invalid, deduplicate by (title, type) keeping the highest-ranked, and normalize category names."""
|
||||
overrides = overrides or {}
|
||||
best = {}
|
||||
for t in templates:
|
||||
if not is_valid_template(t):
|
||||
log.warning(f'Skipping invalid template: {t.get("title", "<no title>")}')
|
||||
continue
|
||||
problems = schema_errors(t)
|
||||
if problems:
|
||||
log.warning(f'Skipping template that fails the schema: {t["title"]} '
|
||||
f'from {t["_source"]} ({problems[0]})')
|
||||
continue
|
||||
key = (normalize_string(t['title']), t.get('type', 1))
|
||||
t_is_local = t.get('_local', False)
|
||||
t_score = template_score(t)
|
||||
if key in best:
|
||||
existing = best[key]
|
||||
existing_is_local = existing.get('_local', False)
|
||||
existing_score = template_score(existing)
|
||||
# Local always beats non-local; among same locality, higher score wins
|
||||
if t_is_local and not existing_is_local:
|
||||
best[key] = t
|
||||
elif not t_is_local and existing_is_local:
|
||||
pass # keep existing
|
||||
elif t_score > existing_score:
|
||||
best[key] = t
|
||||
else:
|
||||
pinned = pinned_source(overrides, key)
|
||||
if key not in best or template_rank(t, pinned) > template_rank(best[key], pinned):
|
||||
best[key] = t
|
||||
result = []
|
||||
for t in best.values():
|
||||
@@ -254,10 +328,24 @@ def deduplicate_and_normalize(templates):
|
||||
result.append(t)
|
||||
return result
|
||||
|
||||
def category_key(label):
|
||||
"""Key that ignores spacing and a plural 's', so near-identical spellings collapse."""
|
||||
key = re.sub(r'[^a-z0-9]', '', label.lower())
|
||||
return key[:-1] if key.endswith('s') and len(key) > 4 else key
|
||||
|
||||
def canonical_categories(templates):
|
||||
"""One spelling per category, so 'Database' and 'Databases' don't split the listings."""
|
||||
counts = Counter(c for t in templates for c in t.get('categories', []))
|
||||
best = {}
|
||||
for label in sorted(counts, key=lambda l: (-len(l.split()), -counts[l], l)):
|
||||
best.setdefault(category_key(label), label)
|
||||
for t in templates:
|
||||
if t.get('categories'):
|
||||
t['categories'] = list(dict.fromkeys(best[category_key(c)] for c in t['categories']))
|
||||
return len(counts) - len(best)
|
||||
|
||||
def postfix_ambiguous_titles(templates):
|
||||
"""Append type labels to titles that appear with multiple types."""
|
||||
from collections import Counter
|
||||
|
||||
# Pass 1: postfix titles that share a normalized name across different types
|
||||
title_counts = Counter(normalize_string(t['title']) for t in templates)
|
||||
ambiguous = {title for title, count in title_counts.items() if count > 1}
|
||||
@@ -278,27 +366,74 @@ def postfix_ambiguous_titles(templates):
|
||||
label = TYPE_LABELS.get(tmpl_type, f'type{tmpl_type}')
|
||||
t['title'] = f"{t['title']} ({label})"
|
||||
|
||||
def missing_sources():
|
||||
"""Names from sources.csv with no downloaded file in sources/external/."""
|
||||
with open(os.path.join(BASE_DIR, 'sources.csv')) as f:
|
||||
expected = {row[0].strip() + '.json' for row in csv.reader(f)
|
||||
if len(row) > 1 and row[0].strip() and row[1].strip()}
|
||||
def audit_overrides(templates, overrides):
|
||||
"""Warn about overrides that stopped earning their place, so the file doesn't go stale."""
|
||||
groups = {}
|
||||
for t in templates:
|
||||
if is_valid_template(t):
|
||||
groups.setdefault((normalize_string(t['title']), t.get('type', 1)), []).append(t)
|
||||
for key, entry in sorted(overrides.items()):
|
||||
where = f'{entry["title"]} (type {key[1]})'
|
||||
candidates = groups.get(key)
|
||||
if not candidates:
|
||||
log.warning(f'Stale override: no template matches {where}')
|
||||
continue
|
||||
prefer = entry.get('prefer')
|
||||
if not prefer:
|
||||
continue
|
||||
pinned = f'{prefer}.json'
|
||||
if not any(t['_source'] == pinned for t in candidates):
|
||||
log.warning(f'Ineffective override: {prefer} has no copy of {where}, using normal priority')
|
||||
elif max(candidates, key=template_rank) is max(candidates, key=lambda t: template_rank(t, pinned)):
|
||||
log.warning(f'Redundant override: normal priority already picks {prefer} for {where}')
|
||||
|
||||
def audit_app_sources(templates, sources):
|
||||
"""Warn when a local copy shadows an app source, since the author's updates never publish"""
|
||||
local = {}
|
||||
for t in templates:
|
||||
if t['_priority'] == 0 and isinstance(t.get('title'), str):
|
||||
local.setdefault((normalize_string(t['title']), t.get('type', 1)), t['_source'])
|
||||
app_files = {s.filename for s in sources if s.is_app}
|
||||
for t in templates:
|
||||
if t['_source'] not in app_files or not isinstance(t.get('title'), str):
|
||||
continue
|
||||
shadowed_by = local.get((normalize_string(t['title']), t.get('type', 1)))
|
||||
if shadowed_by:
|
||||
log.warning(f'{t["title"]} from the {t["_source"]} app source is shadowed by the '
|
||||
f'copy in sources/local/{shadowed_by}, so the author\'s updates never '
|
||||
'get published. Delete the local copy to let them through.')
|
||||
|
||||
def missing_sources(sources, kind):
|
||||
"""Sources of one kind that sources.csv lists but nothing was downloaded for."""
|
||||
external_dir = os.path.join(SOURCES_DIR, 'external')
|
||||
present = set(os.listdir(external_dir)) if os.path.isdir(external_dir) else set()
|
||||
return expected - present
|
||||
return sorted(s.name for s in sources if s.kind == kind and s.filename not in present)
|
||||
|
||||
if __name__ == '__main__':
|
||||
banner('Combine', 'Merge, normalize + dedupe template sources into templates.json')
|
||||
raw = normalize_template_fields(load_sources())
|
||||
overrides = load_overrides()
|
||||
sources = sources_list.load()
|
||||
raw = normalize_template_fields(load_sources(sources))
|
||||
sources_count = len({t.get('_source') for t in raw})
|
||||
log.info(f'Normalized {len(raw)} templates from {sources_count} sources')
|
||||
templates = deduplicate_and_normalize(raw)
|
||||
if any(s.is_app for s in sources):
|
||||
audit_app_sources(raw, sources)
|
||||
if overrides:
|
||||
log.info(f'Applying {len(overrides)} template overrides')
|
||||
audit_overrides(raw, overrides)
|
||||
templates = deduplicate_and_normalize(raw, overrides)
|
||||
log.info(f'{len(templates)} unique templates after dedup ({len(raw) - len(templates)} removed)')
|
||||
merged = canonical_categories(templates)
|
||||
if merged:
|
||||
log.info(f'Merged {merged} duplicate category spellings')
|
||||
filled = backfill_app_source(templates, raw)
|
||||
if filled:
|
||||
log.info(f'Restored the app source link on {filled} descriptions')
|
||||
postfix_ambiguous_titles(templates)
|
||||
# Strip internal tags
|
||||
for t in templates:
|
||||
t.pop('_source', None)
|
||||
t.pop('_local', None)
|
||||
t.pop('_priority', None)
|
||||
templates.sort(key=lambda t: t['title'].lower())
|
||||
for i, t in enumerate(templates, start=1):
|
||||
t['id'] = i
|
||||
@@ -308,10 +443,15 @@ if __name__ == '__main__':
|
||||
previous = len(json.load(f)['templates'])
|
||||
except (OSError, ValueError, KeyError):
|
||||
previous = 0
|
||||
# A failed source download must not silently shrink the published list
|
||||
missing = missing_sources()
|
||||
# A missing app source only costs its own app, so it's left out rather than fatal
|
||||
unavailable = missing_sources(sources, sources_list.APP)
|
||||
if unavailable:
|
||||
log.warning(f'{len(unavailable)} app sources were not downloaded, so they are left '
|
||||
f'out of this build: {", ".join(unavailable)}')
|
||||
# A failed collection download must not silently shrink the published list
|
||||
missing = missing_sources(sources, sources_list.COLLECTION)
|
||||
if missing and not os.environ.get('ALLOW_SHRINK'):
|
||||
log.error(f'Refusing to write: missing external sources: {", ".join(sorted(missing))}. '
|
||||
log.error(f'Refusing to write: missing external sources: {", ".join(missing)}. '
|
||||
'Set ALLOW_SHRINK=1 if this is intentional.')
|
||||
sys.exit(1)
|
||||
if len(templates) < previous * 0.9 and not os.environ.get('ALLOW_SHRINK'):
|
||||
|
||||
+34
-55
@@ -1,10 +1,8 @@
|
||||
import os
|
||||
import csv
|
||||
import sys
|
||||
import time
|
||||
import requests
|
||||
import json
|
||||
|
||||
import sources_list
|
||||
from log import get_logger, banner
|
||||
|
||||
log = get_logger()
|
||||
@@ -12,75 +10,56 @@ log = get_logger()
|
||||
dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
destination_dir = os.path.join(dir, '../sources/external')
|
||||
sources_list = os.path.join(dir, '../sources.csv')
|
||||
|
||||
# Downloads the templates file from a given URL, to the local destination
|
||||
def download(url: str, filename: str, maintainer: str):
|
||||
file_path = os.path.join(destination_dir, filename)
|
||||
log.info(f'Downloading {url}')
|
||||
sourceJson = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
r = requests.get(url, timeout=30)
|
||||
r.raise_for_status()
|
||||
sourceJson = r.json()
|
||||
break
|
||||
except (requests.RequestException, ValueError) as err:
|
||||
if attempt == 2:
|
||||
log.warning(f'Skipping source due to an error: {url} ({err})')
|
||||
return False
|
||||
time.sleep(2 ** attempt)
|
||||
# Downloads the templates file for a given source, to the local destination
|
||||
def download(source):
|
||||
log.info(f'Downloading {source.url}')
|
||||
payload, error = sources_list.fetch_json(source.url)
|
||||
if error:
|
||||
log.warning(f'Skipping source due to an error: {source.url} ({error})')
|
||||
return False
|
||||
|
||||
# Handle sources without valid data
|
||||
if isinstance(sourceJson, list):
|
||||
sourceJson = {'templates': sourceJson}
|
||||
templates = sourceJson.get('templates') if isinstance(sourceJson, dict) else None
|
||||
if not isinstance(templates, list) or not templates:
|
||||
log.warning(f'Skipping source with no templates: {url}')
|
||||
# An app source may publish a bare template object, since it's just the one
|
||||
templates = sources_list.templates_in(payload, allow_bare=source.is_app)
|
||||
if not templates:
|
||||
log.warning(f'Skipping source with no templates: {source.url}')
|
||||
return False
|
||||
|
||||
# 'app' means one author's own app, not a back door for an unreviewed list
|
||||
if source.is_app and len(templates) > sources_list.MAX_APP_TEMPLATES:
|
||||
log.warning(f'Skipping {source.name}: an app source may publish '
|
||||
f'{sources_list.MAX_APP_TEMPLATES} template, but this one has '
|
||||
f'{len(templates)}, so it belongs in sources.csv as a collection')
|
||||
return False
|
||||
|
||||
# Add maintainer field to each template
|
||||
for t in templates:
|
||||
if isinstance(t, dict) and maintainer:
|
||||
t['maintainer'] = maintainer
|
||||
if isinstance(t, dict) and source.maintainer:
|
||||
t['maintainer'] = source.maintainer
|
||||
|
||||
file_path = os.path.join(destination_dir, source.filename)
|
||||
log.debug(f'Saving to {os.path.abspath(file_path)}')
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(sourceJson, f, indent=2, sort_keys=False)
|
||||
json.dump({'templates': templates}, f, indent=2, sort_keys=False)
|
||||
return True
|
||||
|
||||
# Gets list of URLs to download from CSV file
|
||||
def get_source_list():
|
||||
sources = []
|
||||
seen = set()
|
||||
with open(sources_list, mode='r') as file:
|
||||
csvFile = csv.reader(file)
|
||||
for lines in csvFile:
|
||||
row = [col.strip() for col in lines]
|
||||
if len(row) < 2 or not row[0] or not row[1]:
|
||||
if any(row):
|
||||
log.warning(f'Skipping malformed sources.csv row: {lines}')
|
||||
continue
|
||||
if row[0] in seen:
|
||||
log.warning(f'Skipping duplicate source: {row[0]}')
|
||||
continue
|
||||
seen.add(row[0])
|
||||
sources.append(row)
|
||||
return sources
|
||||
|
||||
# Create destination folder if not yet present
|
||||
if not os.path.exists(destination_dir):
|
||||
os.makedirs(destination_dir)
|
||||
|
||||
banner('Download', 'Fetch template sources listed in sources.csv')
|
||||
|
||||
sources = get_source_list()
|
||||
failures = []
|
||||
for source in sources:
|
||||
if not download(source[1], source[0] + '.json', source[2] if len(source) > 2 else ''):
|
||||
failures.append(source[0])
|
||||
sources = sources_list.load()
|
||||
failures = [source for source in sources if not download(source)]
|
||||
|
||||
log.info(f'Downloaded {len(sources) - len(failures)}/{len(sources)} sources')
|
||||
if failures:
|
||||
log.error(f'Failed to download valid sources: {", ".join(failures)}')
|
||||
|
||||
# A lost collection stops the build; a lost app source only costs its own app
|
||||
failed_apps = [source.name for source in failures if source.is_app]
|
||||
failed_collections = [source.name for source in failures if not source.is_app]
|
||||
if failed_apps:
|
||||
log.warning(f'Leaving out {len(failed_apps)} unavailable app '
|
||||
f'sources: {", ".join(failed_apps)}')
|
||||
if failed_collections:
|
||||
log.error(f'Failed to download valid sources: {", ".join(failed_collections)}')
|
||||
sys.exit(1)
|
||||
+53
-25
@@ -2,10 +2,10 @@ import json
|
||||
import html
|
||||
import urllib.parse
|
||||
import os
|
||||
import csv
|
||||
import re
|
||||
import sys
|
||||
|
||||
import sources_list
|
||||
from log import get_logger, banner
|
||||
|
||||
log = get_logger()
|
||||
@@ -14,16 +14,12 @@ current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_dir = os.path.dirname(current_dir)
|
||||
readme_path = os.path.join(project_dir, '.github/README.md')
|
||||
templates_path = os.path.join(project_dir, 'templates.json')
|
||||
sources_path = os.path.join(project_dir, 'sources.csv')
|
||||
external_dir = os.path.join(project_dir, 'sources/external')
|
||||
|
||||
def load_json_file(file_path):
|
||||
with open(file_path, 'r') as file:
|
||||
return json.load(file)
|
||||
|
||||
def load_csv_file(file_path):
|
||||
with open(file_path, 'r') as file:
|
||||
return list(csv.reader(file))
|
||||
|
||||
def slugify(title: str):
|
||||
baseUrl = 'https://portainer-templates.as93.net'
|
||||
slug = re.sub(r'\s+', '-', re.sub(r'[^a-z0-9 ]', '', title.lower()).strip())
|
||||
@@ -50,25 +46,45 @@ def generate_app_list():
|
||||
markdown_content += f"{index+1}. {logo}**[{name}]({slugify(name)} '{description}')** {maintainer_md_link}\n"
|
||||
return markdown_content
|
||||
|
||||
def generate_sources_list():
|
||||
sources = load_csv_file(sources_path)
|
||||
markdown_content = ''
|
||||
def github_user(*urls):
|
||||
"""The GitHub account behind a source, for its avatar: from its url, else its maintainer"""
|
||||
for url in urls:
|
||||
parsed = urllib.parse.urlparse(url or '')
|
||||
path_parts = [p for p in parsed.path.split('/') if p]
|
||||
if parsed.hostname in ('github.com', 'raw.githubusercontent.com') and path_parts:
|
||||
return path_parts[0]
|
||||
return None
|
||||
|
||||
count = 0
|
||||
for source in sources:
|
||||
if len(source) > 1 and source[1].strip():
|
||||
count += 1
|
||||
url = source[1].strip()
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
path_parts = [p for p in parsed_url.path.split('/') if p]
|
||||
if parsed_url.hostname in ('github.com', 'raw.githubusercontent.com') and path_parts:
|
||||
username = path_parts[0]
|
||||
avatar = f'<img src="https://github.com/{username}.png?size=40" width="26" height="26" />'
|
||||
markdown_content += f"{count}. {avatar} [template]({url}) by [@{username}](https://github.com/{username})\n"
|
||||
else:
|
||||
markdown_content += f"{count}. [template]({url})\n"
|
||||
def source_credit(source, label):
|
||||
"""One line of a README credit list: avatar, link and the author's handle"""
|
||||
username = github_user(source.url, source.maintainer)
|
||||
if not username:
|
||||
return f'[{label}]({source.url})'
|
||||
avatar = f'<img src="https://github.com/{username}.png?size=40" width="26" height="26" />'
|
||||
return (f'{avatar} [{label}]({source.url}) by '
|
||||
f'[@{username}](https://github.com/{username})')
|
||||
|
||||
return markdown_content
|
||||
def app_label(source):
|
||||
"""An app's own title from what was downloaded, so the credit reads Cantinarr not cantinarr"""
|
||||
try:
|
||||
templates = load_json_file(os.path.join(external_dir, source.filename))['templates']
|
||||
titles = [t['title'] for t in templates if isinstance(t, dict) and t.get('title')]
|
||||
except (OSError, ValueError, KeyError):
|
||||
titles = []
|
||||
return titles[0] if titles else source.name
|
||||
|
||||
def generate_sources_list(sources):
|
||||
"""The collections: other people's maintained lists of templates"""
|
||||
return ''.join(f'{index}. {source_credit(source, "template")}\n'
|
||||
for index, source in enumerate(sources, start=1))
|
||||
|
||||
def generate_app_sources_list(sources):
|
||||
"""The individual apps, each published and kept up to date by its own author"""
|
||||
if not sources:
|
||||
return ('_None yet - if you maintain a self-hosted app, '
|
||||
'[add yours](CONTRIBUTING.md#option-1-publish-your-own-template-recommended)._')
|
||||
return ''.join(f'{index}. {source_credit(source, app_label(source))}\n'
|
||||
for index, source in enumerate(sources, start=1))
|
||||
|
||||
def insert_content_between_markers(file_path, start_marker, end_marker, content_to_insert):
|
||||
with open(file_path, 'r') as file:
|
||||
@@ -95,8 +111,10 @@ def insert_content_between_markers(file_path, start_marker, end_marker, content_
|
||||
|
||||
banner('List', 'Render app + source lists into the README')
|
||||
|
||||
# Insert sources list into readme
|
||||
sources_md = generate_sources_list()
|
||||
all_sources = sources_list.load()
|
||||
|
||||
# Insert the collection sources list into readme
|
||||
sources_md = generate_sources_list([s for s in all_sources if not s.is_app])
|
||||
insert_content_between_markers(
|
||||
readme_path,
|
||||
'<!-- auto-insert-sources:start -->',
|
||||
@@ -105,6 +123,16 @@ insert_content_between_markers(
|
||||
)
|
||||
log.info(f'Rendered {sources_md.count(chr(10))} sources into README')
|
||||
|
||||
# Insert the individual app sources list into readme
|
||||
app_sources = [s for s in all_sources if s.is_app]
|
||||
insert_content_between_markers(
|
||||
readme_path,
|
||||
'<!-- auto-insert-app-sources:start -->',
|
||||
'<!-- auto-insert-app-sources:end -->',
|
||||
generate_app_sources_list(app_sources),
|
||||
)
|
||||
log.info(f'Rendered {len(app_sources)} individual app sources into README')
|
||||
|
||||
# Insert app list into readme
|
||||
apps_md = generate_app_list()
|
||||
insert_content_between_markers(
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Reads sources.csv (name, url, maintainer, kind) and fetches what it points at."""
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import NamedTuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from log import get_logger
|
||||
|
||||
log = get_logger()
|
||||
|
||||
SOURCES_CSV = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'sources.csv')
|
||||
|
||||
# 'collection' is a list of many apps, 'app' is one app published by its own author
|
||||
COLLECTION = 'collection'
|
||||
APP = 'app'
|
||||
KINDS = (COLLECTION, APP)
|
||||
|
||||
# A source name becomes a file name, so keep it to something unambiguous
|
||||
NAME_PATTERN = re.compile(r'^[a-z0-9][a-z0-9_-]*$')
|
||||
|
||||
# One app, one template: an approved row must not be able to grow entries nobody reviewed
|
||||
MAX_APP_TEMPLATES = 1
|
||||
|
||||
class Source(NamedTuple):
|
||||
"""One usable row of sources.csv"""
|
||||
name: str
|
||||
url: str
|
||||
maintainer: str
|
||||
kind: str
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
"""Where download.py saves it, and the name combine.py ranks it by"""
|
||||
return f'{self.name}.json'
|
||||
|
||||
@property
|
||||
def is_app(self):
|
||||
return self.kind == APP
|
||||
|
||||
def rows(path=SOURCES_CSV):
|
||||
"""Every non-blank row, as (line number, stripped cells)"""
|
||||
with open(path, newline='') as file:
|
||||
for line, row in enumerate(csv.reader(file), start=1):
|
||||
cells = [cell.strip() for cell in row]
|
||||
if any(cells):
|
||||
yield line, cells
|
||||
|
||||
def row_problems(cells):
|
||||
"""Everything wrong with a row, as (level, message) pairs; any problem makes load() skip it"""
|
||||
if len(cells) < 2 or not cells[0] or not cells[1]:
|
||||
return [('warning', f'malformed row, would be skipped: {cells}')]
|
||||
problems = []
|
||||
if not NAME_PATTERN.match(cells[0]):
|
||||
problems.append(('error', f'name {cells[0]!r} becomes a file name, so it may only hold '
|
||||
"lowercase letters, digits, '_' and '-'"))
|
||||
if urlparse(cells[1]).scheme not in ('http', 'https'):
|
||||
problems.append(('error', f'{cells[0]} has a non-http(s) url: {cells[1]}'))
|
||||
kind = cells[3] if len(cells) > 3 else ''
|
||||
if kind and kind.lower() not in KINDS:
|
||||
problems.append(('error', f'{cells[0]} has kind {kind!r}, expected '
|
||||
f'{" or ".join(KINDS)}'))
|
||||
if len(cells) > 4 and any(cells[4:]):
|
||||
problems.append(('error', f'{cells[0]} has {len(cells)} columns, expected at most 4 '
|
||||
'(name, url, maintainer, kind)'))
|
||||
return problems
|
||||
|
||||
def parse(cells):
|
||||
"""A Source from a row already known to be well-formed"""
|
||||
kind = cells[3].lower() if len(cells) > 3 and cells[3] else COLLECTION
|
||||
maintainer = cells[2] if len(cells) > 2 else ''
|
||||
return Source(cells[0], cells[1], maintainer, kind)
|
||||
|
||||
def load(path=SOURCES_CSV):
|
||||
"""Every usable source, in listed order. validate_sources.py reports the rows skipped here"""
|
||||
sources, seen = [], set()
|
||||
for line, cells in rows(path):
|
||||
problems = row_problems(cells)
|
||||
if problems:
|
||||
for _, message in problems:
|
||||
log.warning(f'Skipping sources.csv:{line}: {message}')
|
||||
continue
|
||||
if cells[0] in seen:
|
||||
log.warning(f'Skipping sources.csv:{line}: duplicate source {cells[0]!r}')
|
||||
continue
|
||||
seen.add(cells[0])
|
||||
sources.append(parse(cells))
|
||||
return sources
|
||||
|
||||
def fetch_json(url, attempts=3):
|
||||
"""Fetch and parse JSON, retrying on a blip. Returns (payload, error), one of them None"""
|
||||
import requests # only the fetching path needs it
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
response = requests.get(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
return response.json(), None
|
||||
except (requests.RequestException, ValueError) as err:
|
||||
if attempt == attempts - 1:
|
||||
return None, str(err)
|
||||
time.sleep(2 ** attempt)
|
||||
|
||||
def templates_in(payload, allow_bare=False):
|
||||
"""The template list in a downloaded source, or None. `allow_bare` takes a lone object"""
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
if isinstance(payload, dict):
|
||||
if isinstance(payload.get('templates'), list):
|
||||
return payload['templates']
|
||||
if allow_bare and isinstance(payload.get('title'), str):
|
||||
return [payload]
|
||||
return None
|
||||
+135
-37
@@ -1,14 +1,13 @@
|
||||
import copy
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from jsonschema import Draft7Validator, FormatChecker
|
||||
|
||||
from combine import normalize_template
|
||||
import sources_list
|
||||
from combine import normalize_template, normalize_string
|
||||
from log import get_logger, banner
|
||||
|
||||
log = get_logger()
|
||||
@@ -21,6 +20,7 @@ VALIDATOR = Draft7Validator(ITEM_SCHEMA, format_checker=FormatChecker())
|
||||
# Absence of these only warrants a warning under the Moderate quality gate
|
||||
RECOMMENDED = ['logo', 'categories', 'note', 'platform', 'restart_policy']
|
||||
SOURCE_EXTENSIONS = ('.json', '.yml', '.yaml', '.csv')
|
||||
OVERRIDE_STATUSES = ('ok', 'unmaintained', 'broken')
|
||||
|
||||
def load_json(path):
|
||||
with open(path) as file:
|
||||
@@ -43,19 +43,9 @@ def quality_warnings(where, template):
|
||||
for name, count in Counter(names).items() if count > 1]
|
||||
return warnings
|
||||
|
||||
def check_source(path):
|
||||
"""Validate one local source: each template must normalize to a schema-valid entry"""
|
||||
try:
|
||||
data = load_json(path)
|
||||
except (OSError, json.JSONDecodeError) as err:
|
||||
return [f'{path}: could not read JSON ({err})'], []
|
||||
|
||||
templates = data.get('templates') if isinstance(data, dict) else None
|
||||
if not isinstance(templates, list):
|
||||
return [f'{path}: missing top-level "templates" array'], []
|
||||
|
||||
name = os.path.basename(path)
|
||||
errors, warnings, titles = [], [], Counter()
|
||||
def check_templates(name, templates):
|
||||
"""Validate a list of templates: each must normalize to a schema-valid entry"""
|
||||
errors, warnings, seen = [], [], Counter()
|
||||
if not templates:
|
||||
warnings.append(f'{name}: contains no templates')
|
||||
for index, raw in enumerate(templates):
|
||||
@@ -65,7 +55,6 @@ def check_source(path):
|
||||
continue
|
||||
title = raw.get('title')
|
||||
if isinstance(title, str) and title.strip():
|
||||
titles[title.strip()] += 1
|
||||
where = f'{name} "{title.strip()}"'
|
||||
# Normalize a copy exactly as combine.py would, then judge the published result
|
||||
try:
|
||||
@@ -78,11 +67,54 @@ def check_source(path):
|
||||
errors += [f'{where}: {e.message}' for e in VALIDATOR.iter_errors(template)]
|
||||
errors += stackfile_errors(where, template)
|
||||
warnings += quality_warnings(where, template)
|
||||
# Keyed like combine's dedup, so one app may ship both a container and a stack
|
||||
if isinstance(title, str) and title.strip():
|
||||
seen[(normalize_string(title), template.get('type', 1))] += 1
|
||||
|
||||
errors += [f'{name}: duplicate title {title!r} ({count} templates)'
|
||||
for title, count in titles.items() if count > 1]
|
||||
errors += [f'{name}: duplicate template {title!r} of type {kind} ({count} copies), '
|
||||
'so all but one would be dropped'
|
||||
for (title, kind), count in seen.items() if count > 1]
|
||||
return errors, warnings
|
||||
|
||||
def check_source(path):
|
||||
"""Validate one local source: each template must normalize to a schema-valid entry"""
|
||||
try:
|
||||
data = load_json(path)
|
||||
except (OSError, json.JSONDecodeError) as err:
|
||||
return [f'{path}: could not read JSON ({err})'], []
|
||||
|
||||
templates = data.get('templates') if isinstance(data, dict) else None
|
||||
if not isinstance(templates, list):
|
||||
return [f'{path}: missing top-level "templates" array'], []
|
||||
|
||||
return check_templates(os.path.basename(path), templates)
|
||||
|
||||
def url_kind(url):
|
||||
"""The kind sources.csv gives this url, defaulting to app, the stricter of the two"""
|
||||
for source in sources_list.load():
|
||||
if source.url == url:
|
||||
return source.kind
|
||||
return sources_list.APP
|
||||
|
||||
def check_url(url):
|
||||
"""Check a remote source as download.py will: an app per template, a collection on shape"""
|
||||
payload, error = sources_list.fetch_json(url)
|
||||
if error:
|
||||
return [f'{url}: could not fetch valid JSON ({error})'], []
|
||||
|
||||
is_app = url_kind(url) == sources_list.APP
|
||||
templates = sources_list.templates_in(payload, allow_bare=is_app)
|
||||
if not templates:
|
||||
shape = ('one template as a JSON object, or several under a top-level "templates" '
|
||||
'array') if is_app else 'its templates under a top-level "templates" array'
|
||||
return [f'{url}: found no templates, so the build would skip it. Publish {shape}'], []
|
||||
if not is_app:
|
||||
return [], [] # the shape is all the build needs from a collection
|
||||
if len(templates) > sources_list.MAX_APP_TEMPLATES:
|
||||
return [f'{url}: holds {len(templates)} templates, but an app source may publish only '
|
||||
f'{sources_list.MAX_APP_TEMPLATES}, so it has to be listed as a collection'], []
|
||||
return check_templates(url, templates)
|
||||
|
||||
def check_stack(path):
|
||||
"""A compose stack file must be valid YAML with a non-empty services mapping"""
|
||||
try:
|
||||
@@ -99,32 +131,95 @@ def check_stack(path):
|
||||
warnings = [f'{path}: obsolete top-level "version" key'] if 'version' in doc else []
|
||||
return [], warnings
|
||||
|
||||
def local_source_names():
|
||||
"""File names in sources/local/, which a source name can't reuse: combine keys on them"""
|
||||
local_dir = os.path.join(ROOT, 'sources', 'local')
|
||||
if not os.path.isdir(local_dir):
|
||||
return set()
|
||||
return {os.path.splitext(f)[0] for f in os.listdir(local_dir) if f.endswith('.json')}
|
||||
|
||||
def check_csv(path):
|
||||
"""Validate sources.csv: each row needs a unique name and an http(s) url"""
|
||||
"""Validate sources.csv: unique file-name-safe name, http(s) url, recognized kind"""
|
||||
try:
|
||||
with open(path, newline='') as file:
|
||||
rows = list(csv.reader(file))
|
||||
rows = list(sources_list.rows(path))
|
||||
except OSError as err:
|
||||
return [f'{path}: could not read ({err})'], []
|
||||
|
||||
local_names = local_source_names()
|
||||
errors, warnings, seen = [], [], set()
|
||||
for line, row in enumerate(rows, start=1):
|
||||
cells = [cell.strip() for cell in row]
|
||||
if not any(cells):
|
||||
for line, cells in rows:
|
||||
for level, message in sources_list.row_problems(cells):
|
||||
(errors if level == 'error' else warnings).append(f'{path}:{line}: {message}')
|
||||
name = cells[0]
|
||||
if not name:
|
||||
continue
|
||||
if len(cells) < 2 or not cells[0] or not cells[1]:
|
||||
warnings.append(f'{path}:{line}: malformed row, would be skipped: {row}')
|
||||
continue
|
||||
name, url = cells[0], cells[1]
|
||||
if name in seen:
|
||||
errors.append(f'{path}:{line}: duplicate source name {name!r}')
|
||||
seen.add(name)
|
||||
if urlparse(url).scheme not in ('http', 'https'):
|
||||
errors.append(f'{path}:{line}: {name} has a non-http(s) url: {url}')
|
||||
if name in local_names:
|
||||
errors.append(f'{path}:{line}: name {name!r} collides with '
|
||||
f'sources/local/{name}.json, which would leave the two '
|
||||
'indistinguishable when picking between duplicate templates')
|
||||
return errors, warnings
|
||||
|
||||
def source_names():
|
||||
"""Source names from sources.csv, used to check an override's "prefer" target exists"""
|
||||
try:
|
||||
return {source.name for source in sources_list.load()}
|
||||
except OSError:
|
||||
return set()
|
||||
|
||||
def check_overrides(path):
|
||||
"""Validate overrides.json: each entry must name a real template and actually do something"""
|
||||
try:
|
||||
data = load_json(path)
|
||||
except (OSError, json.JSONDecodeError) as err:
|
||||
return [f'{path}: could not read JSON ({err})'], []
|
||||
|
||||
entries = data.get('overrides') if isinstance(data, dict) else None
|
||||
if not isinstance(entries, list):
|
||||
return [f'{path}: missing top-level "overrides" array'], []
|
||||
|
||||
name = os.path.basename(path)
|
||||
known = source_names()
|
||||
errors, warnings, keys = [], [], Counter()
|
||||
for index, entry in enumerate(entries):
|
||||
where = f'{name}#{index}'
|
||||
if not isinstance(entry, dict):
|
||||
errors.append(f'{where}: override is not an object')
|
||||
continue
|
||||
title = entry.get('title')
|
||||
if not (isinstance(title, str) and title.strip()):
|
||||
errors.append(f'{where}: missing "title"')
|
||||
continue
|
||||
where = f'{name} "{title.strip()}"'
|
||||
template_type = entry.get('type', 1)
|
||||
if not isinstance(template_type, int) or template_type not in (1, 2, 3, 4):
|
||||
errors.append(f'{where}: "type" must be 1, 2, 3 or 4')
|
||||
continue
|
||||
keys[(normalize_string(title), template_type)] += 1
|
||||
prefer = entry.get('prefer')
|
||||
if prefer is not None and prefer not in known:
|
||||
errors.append(f'{where}: "prefer" names an unknown source {prefer!r}')
|
||||
status = entry.get('status')
|
||||
if status is not None and status not in OVERRIDE_STATUSES:
|
||||
errors.append(f'{where}: "status" must be one of {", ".join(OVERRIDE_STATUSES)}')
|
||||
if not any(entry.get(key) for key in ('prefer', 'status', 'note')):
|
||||
warnings.append(f'{where}: has no "prefer", "status" or "note", so it does nothing')
|
||||
|
||||
errors += [f'{name}: duplicate override for {title!r} (type {kind}, {count} entries)'
|
||||
for (title, kind), count in keys.items() if count > 1]
|
||||
return errors, warnings
|
||||
|
||||
def is_url(target):
|
||||
return target.startswith(('http://', 'https://'))
|
||||
|
||||
def validate_path(path):
|
||||
"""Dispatch a path to the right checker by extension"""
|
||||
"""Dispatch a target to the right checker, by url then file name then extension"""
|
||||
if is_url(path):
|
||||
return check_url(path)
|
||||
if os.path.basename(path) == 'overrides.json':
|
||||
return check_overrides(path)
|
||||
if path.endswith('.csv'):
|
||||
return check_csv(path)
|
||||
if path.endswith(('.yml', '.yaml')):
|
||||
@@ -135,7 +230,9 @@ def expand(targets):
|
||||
"""Turn any directory argument into the source files it contains (recursively)"""
|
||||
paths = []
|
||||
for target in targets:
|
||||
if os.path.isdir(target):
|
||||
if is_url(target):
|
||||
paths.append(target)
|
||||
elif os.path.isdir(target):
|
||||
for root, _, names in os.walk(target):
|
||||
paths += [os.path.join(root, n) for n in names if n.endswith(SOURCE_EXTENSIONS)]
|
||||
else:
|
||||
@@ -143,19 +240,20 @@ def expand(targets):
|
||||
return sorted(paths)
|
||||
|
||||
def main():
|
||||
banner('Validate sources', 'Check template sources, stack files + sources.csv')
|
||||
banner('Validate sources', 'Check template sources, stack files, sources.csv + overrides')
|
||||
targets = sys.argv[1:] or [os.path.join(ROOT, p) for p in
|
||||
('sources/local', 'sources/stacks', 'sources.csv')]
|
||||
('sources/local', 'sources/stacks', 'sources.csv', 'overrides.json')]
|
||||
|
||||
errors, warnings = [], []
|
||||
for path in expand(targets):
|
||||
if not os.path.exists(path):
|
||||
if not is_url(path) and not os.path.exists(path):
|
||||
log.warning(f'Skipping missing path: {path}')
|
||||
continue
|
||||
file_errors, file_warnings = validate_path(path)
|
||||
errors += file_errors
|
||||
warnings += file_warnings
|
||||
log.info(f'Checked {os.path.relpath(path, ROOT)}: '
|
||||
where = path if is_url(path) else os.path.relpath(path, ROOT)
|
||||
log.info(f'Checked {where}: '
|
||||
f'{len(file_errors)} errors, {len(file_warnings)} warnings')
|
||||
|
||||
for warning in warnings:
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"overrides": [
|
||||
{
|
||||
"title": "Elasticsearch",
|
||||
"status": "broken",
|
||||
"note": "No source has a working image. Portainer's copy pins 7.15.1 from 2021, and every other copy uses the `elasticsearch:latest` tag, which upstream does not publish. Elasticsearch ships no rolling tag at all, so a version has to be pinned and bumped by hand. Maintained in sources/local/development_templates.json.",
|
||||
"updated": "2026-09-23"
|
||||
},
|
||||
{
|
||||
"title": "Jenkins",
|
||||
"prefer": "mikestraney_templates",
|
||||
"reason": "Portainer's copy pins the retired lts-jdk11 tag; this one tracks the current LTS line",
|
||||
"updated": "2026-09-23"
|
||||
},
|
||||
{
|
||||
"title": "MySQL",
|
||||
"prefer": "mikestraney_templates",
|
||||
"reason": "Portainer's copy uses mysql/mysql-server:5.7, a repository Oracle retired on a release that reached end of life in October 2023",
|
||||
"updated": "2026-09-23"
|
||||
},
|
||||
{
|
||||
"title": "ruTorrent",
|
||||
"prefer": "novaspirit_templates",
|
||||
"reason": "Every other source uses linuxserver/rutorrent, which LinuxServer deprecated and last built in 2021",
|
||||
"updated": "2026-09-23"
|
||||
},
|
||||
{
|
||||
"title": "TiddlyWiki",
|
||||
"prefer": "novaspirit_templates",
|
||||
"reason": "Every other source uses mazzolino/tiddlywiki, last built 2020; nicolaw's is still maintained",
|
||||
"updated": "2026-09-23"
|
||||
}
|
||||
]
|
||||
}
|
||||
+20
-10
@@ -1,10 +1,20 @@
|
||||
dnburgess_templates, https://raw.githubusercontent.com/dnburgess/self-hosted-template/master/template.json, https://github.com/dnburgess/self-hosted-template/
|
||||
qballjos_templates, https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json, https://github.com/Qballjos/portainer_templates/
|
||||
selfhostedpro_templates, https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/portainer-2.0/Template/template.json, https://github.com/SelfhostedPro/selfhosted_templates/
|
||||
technorabilia_templates, https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates.json, https://github.com/technorabilia/portainer-templates/
|
||||
mikestraney_templates, https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json, https://github.com/mikestraney/portainer-templates/
|
||||
xneo1_templates, https://raw.githubusercontent.com/xneo1/portainer_templates/master/Template/template.json, https://github.com/xneo1/portainer_templates/
|
||||
novaspirit_templates, https://raw.githubusercontent.com/novaspirit/pi-hosted/master/pi-hosted_template/template/portainer-v2.json, https://github.com/novaspirit/pi-hosted/
|
||||
mediadepot_templates, https://raw.githubusercontent.com/mediadepot/templates/master/portainer.json, https://github.com/mediadepot/templates/
|
||||
shmolf_templates, https://raw.githubusercontent.com/shmolf/portainer-templates/main/templates-2.0.json, https://github.com/shmolf/portainer-templates/
|
||||
portainer_templates, https://raw.githubusercontent.com/portainer/templates/v3/templates.json, https://github.com/portainer/templates/
|
||||
portainer_templates, https://raw.githubusercontent.com/portainer/templates/v3/templates.json, https://github.com/portainer/templates/, collection
|
||||
technorabilia_templates, https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates.json, https://github.com/technorabilia/portainer-templates/, collection
|
||||
xneo1_templates, https://raw.githubusercontent.com/xneo1/portainer_templates/master/Template/template.json, https://github.com/xneo1/portainer_templates/, collection
|
||||
mediadepot_templates, https://raw.githubusercontent.com/mediadepot/templates/master/portainer.json, https://github.com/mediadepot/templates/, collection
|
||||
novaspirit_templates, https://raw.githubusercontent.com/novaspirit/pi-hosted/master/pi-hosted_template/template/portainer-v2.json, https://github.com/novaspirit/pi-hosted/, collection
|
||||
qballjos_templates, https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json, https://github.com/Qballjos/portainer_templates/, collection
|
||||
shmolf_templates, https://raw.githubusercontent.com/shmolf/portainer-templates/main/templates-2.0.json, https://github.com/shmolf/portainer-templates/, collection
|
||||
mikestraney_templates, https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json, https://github.com/mikestraney/portainer-templates/, collection
|
||||
selfhostedpro_templates, https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/portainer-2.0/Template/template.json, https://github.com/SelfhostedPro/selfhosted_templates/, collection
|
||||
dnburgess_templates, https://raw.githubusercontent.com/dnburgess/self-hosted-template/master/template.json, https://github.com/dnburgess/self-hosted-template/, collection
|
||||
deployable_sh_templates, https://deployable.sh/portainer/templates.json, https://github.com/deployable-sh/stacks/, collection
|
||||
notaflightrisk_templates, https://raw.githubusercontent.com/NotAFlightRisk/portainer-templates-extras/main/templates.json, https://github.com/NotAFlightRisk/portainer-templates-extras/, collection
|
||||
homedex, https://raw.githubusercontent.com/HarshShah0203/homedex/main/portainer-template.json, https://github.com/HarshShah0203/homedex/, app
|
||||
replayhaven, https://raw.githubusercontent.com/SauerExe/ReplayHaven/main/portainer-template.json, https://github.com/SauerExe/ReplayHaven/, app
|
||||
ai-marketplace-monitor, https://raw.githubusercontent.com/BoPeng/ai-marketplace-monitor/main/deploy/portainer/templates.json, https://github.com/BoPeng/ai-marketplace-monitor/, app
|
||||
libredb_studio, https://raw.githubusercontent.com/libredb/libredb-studio/main/portainer-template.json, https://github.com/libredb/libredb-studio/, app
|
||||
seawise_client, https://raw.githubusercontent.com/seawise-io/seawise-client/main/portainer-template.json, https://github.com/seawise-io/seawise-client/, app
|
||||
opentodo, https://raw.githubusercontent.com/OpenTodoApp/OpenTodo/main/deploy/portainer/portainer-template.json, https://github.com/OpenTodoApp/OpenTodo/, app
|
||||
armanckeser_selfhost_apps, https://armanckeser.github.io/selfhost-apps/portainer/templates.json, https://github.com/armanckeser/selfhost-apps/, collection
|
||||
yuvomi, https://raw.githubusercontent.com/ulsklyc/yuvomi/main/deploy/portainer/portainer-template.json, https://github.com/ulsklyc/yuvomi/, app
|
||||
|
@@ -45,28 +45,63 @@
|
||||
"description": "Self-hostable AI config management platform for AGENTS.md, CLAUDE.md, Cursor rules, and 30+ AI assistant formats. Source: https://github.com/GeiserX/LynxPrompt",
|
||||
"env": [
|
||||
{
|
||||
"default": "",
|
||||
"label": "Session secret \u2014 generate with: openssl rand -base64 32",
|
||||
"description": "Generate with: openssl rand -hex 32",
|
||||
"label": "Session secret (required)",
|
||||
"name": "NEXTAUTH_SECRET"
|
||||
},
|
||||
{
|
||||
"default": "http://localhost:3000",
|
||||
"label": "Public base URL",
|
||||
"description": "Generate with: openssl rand -hex 24",
|
||||
"label": "Database password (required)",
|
||||
"name": "DB_PASSWORD"
|
||||
},
|
||||
{
|
||||
"description": "Such as http://192.168.1.10:3000 or https://lynxprompt.example.com",
|
||||
"label": "Address you open it on (required)",
|
||||
"name": "APP_URL"
|
||||
},
|
||||
{
|
||||
"description": "Sign-in is a link sent by mail",
|
||||
"label": "SMTP server (required)",
|
||||
"name": "SMTP_HOST"
|
||||
},
|
||||
{
|
||||
"description": "From address of the sign-in mails",
|
||||
"label": "Sender address (required)",
|
||||
"name": "SMTP_FROM"
|
||||
},
|
||||
{
|
||||
"default": "587",
|
||||
"description": "465 uses TLS",
|
||||
"label": "SMTP port",
|
||||
"name": "SMTP_PORT"
|
||||
},
|
||||
{
|
||||
"default": "",
|
||||
"label": "Super-admin account email",
|
||||
"description": "Leave empty if the server needs no login",
|
||||
"label": "SMTP user",
|
||||
"name": "SMTP_USER"
|
||||
},
|
||||
{
|
||||
"default": "",
|
||||
"label": "SMTP password",
|
||||
"name": "SMTP_PASSWORD"
|
||||
},
|
||||
{
|
||||
"default": "",
|
||||
"description": "This address becomes super-admin on its first sign-in",
|
||||
"label": "Super-admin email",
|
||||
"name": "SUPERADMIN_EMAIL"
|
||||
}
|
||||
],
|
||||
"logo": "https://avatars.githubusercontent.com/u/9169332",
|
||||
"name": "lynxprompt",
|
||||
"note": "LynxPrompt listens on port 3000 and keeps its database in the pgdata volume and uploaded images in the uploads volume. Sign-in is a link sent by mail, so fill in the SMTP fields, and set APP_URL to the address you open it on. Generate the secrets with openssl rand -hex 32 and openssl rand -hex 24. All options: https://github.com/GeiserX/LynxPrompt/blob/main/env.example",
|
||||
"platform": "linux",
|
||||
"repository": {
|
||||
"stackfile": "sources/stacks/lynxprompt.yml",
|
||||
"url": "https://github.com/lissy93/portainer-templates"
|
||||
},
|
||||
"restart_policy": "unless-stopped",
|
||||
"title": "LynxPrompt",
|
||||
"type": 3
|
||||
}
|
||||
|
||||
@@ -153,6 +153,41 @@
|
||||
"restart_policy": "unless-stopped",
|
||||
"title": "Gotenberg",
|
||||
"type": 1
|
||||
},
|
||||
{
|
||||
"categories": [
|
||||
"Database"
|
||||
],
|
||||
"description": "Open-source search and analytics engine",
|
||||
"env": [
|
||||
{
|
||||
"default": "single-node",
|
||||
"name": "discovery.type",
|
||||
"preset": true
|
||||
},
|
||||
{
|
||||
"default": "false",
|
||||
"label": "Disable X-Pack security",
|
||||
"name": "xpack.security.enabled"
|
||||
}
|
||||
],
|
||||
"image": "elasticsearch:9.5.3",
|
||||
"logo": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/elasticsearch.png",
|
||||
"name": "elasticsearch",
|
||||
"note": "Elasticsearch publishes no rolling <code>latest</code> tag, so this template pins a specific version. <code>discovery.type</code> and <code>xpack.security.enabled</code> are set so a single-node container starts and stays reachable without TLS setup.",
|
||||
"platform": "linux",
|
||||
"ports": [
|
||||
"9200/tcp",
|
||||
"9300/tcp"
|
||||
],
|
||||
"restart_policy": "unless-stopped",
|
||||
"title": "Elasticsearch",
|
||||
"type": 1,
|
||||
"volumes": [
|
||||
{
|
||||
"container": "/usr/share/elasticsearch/data"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"version": "3"
|
||||
|
||||
@@ -275,10 +275,10 @@
|
||||
"Media",
|
||||
"Tools"
|
||||
],
|
||||
"description": "One app over your whole media stack: household members browse and request movies, TV, books, and music, and admins get Radarr, Sonarr, Chaptarr, and Lidarr control down to the individual episode, live queues for SABnzbd, qBittorrent, NZBGet, and Transmission, Tautulli activity, Plex invites, plain-English fixes for stuck downloads, and an MCP server for Claude or any MCP client. GitHub: [windoze95/cantinarr](https://github.com/windoze95/cantinarr)",
|
||||
"description": "Media requests and server management in one app. Household members browse and request movies, shows, books, and music, follow downloads, and get notified when content is ready. Administrators manage Radarr, Sonarr, Chaptarr, Lidarr, and download clients, review approvals, and investigate stuck downloads. AI is optional, and authorized MCP clients can use the same server. GitHub: [windoze95/cantinarr](https://github.com/windoze95/cantinarr)",
|
||||
"env": [
|
||||
{
|
||||
"default": "https://push.julian.codes",
|
||||
"default": "https://push.cantinarr.com",
|
||||
"description": "Push gateway for the companion iOS and Android apps. The server enrolls itself on first start; clear to disable push, or point it at your own gateway.",
|
||||
"label": "CANTINARR_PUSH_GATEWAY_URL",
|
||||
"name": "CANTINARR_PUSH_GATEWAY_URL"
|
||||
@@ -291,7 +291,7 @@
|
||||
}
|
||||
],
|
||||
"image": "ghcr.io/windoze95/cantinarr:latest",
|
||||
"logo": "https://raw.githubusercontent.com/windoze95/cantinarr/main/app/web/icons/Icon-512.png",
|
||||
"logo": "https://cantinarr.com/static/symbol-transparent-v1.png",
|
||||
"name": "cantinarr",
|
||||
"note": "Web UI and API on port 8585. Radarr, Sonarr, Chaptarr, download clients, Tautulli, and Plex are all optional and are added from the admin UI after first run. Documentation: https://github.com/windoze95/cantinarr",
|
||||
"platform": "linux",
|
||||
|
||||
@@ -111,6 +111,42 @@
|
||||
"container": "/config"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"categories": [
|
||||
"Monitoring"
|
||||
],
|
||||
"description": "Uptime and synthetic monitoring with status pages, incidents and alerts. Checks HTTP, DNS, TCP, ICMP, TLS expiry, databases, message brokers, heartbeats and headless browser scenarios. Source: https://github.com/fclairamb/solidping",
|
||||
"env": [
|
||||
{
|
||||
"name": "SP_NODE_NAME",
|
||||
"label": "Node name",
|
||||
"default": "solidping",
|
||||
"description": "Worker name, lowercase letters, digits and dashes, starting with a letter (3 to 21 characters). The server refuses to start without a valid one."
|
||||
},
|
||||
{
|
||||
"name": "SP_BASE_URL",
|
||||
"label": "Public URL",
|
||||
"default": "http://localhost:4000",
|
||||
"description": "URL people use to reach SolidPing. Used in links and notifications."
|
||||
}
|
||||
],
|
||||
"image": "ghcr.io/fclairamb/solidping:latest",
|
||||
"logo": "https://raw.githubusercontent.com/fclairamb/solidping/main/res/logo_256.png",
|
||||
"name": "solidping",
|
||||
"note": "Open <code>http://<your-host>:4000</code>. First login: <code>admin@solidping.io</code> / <code>solidpass</code>, organization <code>default</code>. You are asked to set a new password right away. The SQLite database and uploads live in the <code>/data</code> volume. No secret is needed to start. Configuration: <a href=\"https://docs.solidping.io/configuration/\">docs.solidping.io</a>",
|
||||
"platform": "linux",
|
||||
"ports": [
|
||||
"4000:4000/tcp"
|
||||
],
|
||||
"restart_policy": "unless-stopped",
|
||||
"title": "SolidPing",
|
||||
"type": 1,
|
||||
"volumes": [
|
||||
{
|
||||
"container": "/data"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"version": "3"
|
||||
|
||||
@@ -294,7 +294,7 @@
|
||||
"Tools",
|
||||
"Networking"
|
||||
],
|
||||
"description": "Open-source fuel and EV route planner with real-time prices across 36 countries. Source: https://github.com/GeiserX/Pumperly",
|
||||
"description": "Open-source fuel and EV route planner with live prices from 22 countries. Source: https://github.com/GeiserX/Pumperly",
|
||||
"env": [
|
||||
{
|
||||
"default": "ES",
|
||||
@@ -384,6 +384,24 @@
|
||||
"container": "/app/uploads"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"categories": [
|
||||
"Productivity",
|
||||
"Documents"
|
||||
],
|
||||
"description": "Collaborative LaTeX editor you host yourself, an alternative to Overleaf. Real-time editing, a git repository per project, and a sandboxed TeX Live compiler. Source: https://github.com/trahloff/Aldine",
|
||||
"logo": "https://raw.githubusercontent.com/trahloff/Aldine/main/brand/icon.svg",
|
||||
"name": "aldine",
|
||||
"note": "Open <code>http://<your-host>:8080</code>. The stack has two containers: the app, and a compiler that runs TeX Live on an internal network with no internet access, all Linux capabilities dropped and bounded memory and processes. The compiler image is about 2.4 GB to download, so the first deploy takes a while. There is no login by default, so anyone who can reach the port can open every project; to require accounts, edit the stack and add <code>AUTH_ENABLED: \"1\"</code> under the app service's <code>environment</code>. Docs: <a href=\"https://github.com/trahloff/Aldine/blob/main/deploy/README.md\" target=\"_blank\">deploy/README.md</a>",
|
||||
"platform": "linux",
|
||||
"repository": {
|
||||
"stackfile": "docker-compose.yml",
|
||||
"url": "https://github.com/trahloff/Aldine"
|
||||
},
|
||||
"restart_policy": "unless-stopped",
|
||||
"title": "Aldine",
|
||||
"type": 3
|
||||
}
|
||||
],
|
||||
"version": "3"
|
||||
|
||||
@@ -1,46 +1,60 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Postgres 18 refuses a mount at /var/lib/postgresql/data.
|
||||
- pgdata:/var/lib/postgresql
|
||||
environment:
|
||||
POSTGRES_DB: lynxprompt
|
||||
POSTGRES_USER: lynxprompt
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD must be set}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U lynxprompt -d lynxprompt"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
lynxprompt:
|
||||
image: drumsergio/lynxprompt:2.1.0
|
||||
image: drumsergio/lynxprompt:2.1.40
|
||||
platform: linux/amd64
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- APP_URL=${APP_URL:-http://localhost:3000}
|
||||
- NEXTAUTH_URL=${APP_URL:-http://localhost:3000}
|
||||
- SUPERADMIN_EMAIL=${SUPERADMIN_EMAIL:-}
|
||||
- DB_PASSWORD=lynxprompt
|
||||
- DB_APP_HOST=postgres
|
||||
- DB_APP_USER=lynxprompt
|
||||
- DB_APP_PASSWORD=lynxprompt
|
||||
- DB_APP_NAME=lynxprompt
|
||||
- DB_USERS_HOST=postgres
|
||||
- DB_USERS_USER=lynxprompt
|
||||
- DB_USERS_PASSWORD=lynxprompt
|
||||
- DB_USERS_NAME=lynxprompt
|
||||
- DB_BLOG_HOST=postgres
|
||||
- DB_BLOG_USER=lynxprompt
|
||||
- DB_BLOG_PASSWORD=lynxprompt
|
||||
- DB_BLOG_NAME=lynxprompt
|
||||
- DB_SUPPORT_HOST=postgres
|
||||
- DB_SUPPORT_USER=lynxprompt
|
||||
- DB_SUPPORT_PASSWORD=lynxprompt
|
||||
- DB_SUPPORT_NAME=lynxprompt
|
||||
- ENABLE_USER_REGISTRATION=true
|
||||
- ENABLE_EMAIL_AUTH=true
|
||||
- ENABLE_PASSKEYS=true
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_DB=lynxprompt
|
||||
- POSTGRES_USER=lynxprompt
|
||||
- POSTGRES_PASSWORD=lynxprompt
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- uploads:/data/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# One database per Prisma schema; the app creates the other three on first start.
|
||||
DB_APP_HOST: postgres
|
||||
DB_APP_USER: lynxprompt
|
||||
DB_APP_PASSWORD: ${DB_PASSWORD}
|
||||
DB_APP_NAME: lynxprompt
|
||||
DB_USERS_HOST: postgres
|
||||
DB_USERS_USER: lynxprompt
|
||||
DB_USERS_PASSWORD: ${DB_PASSWORD}
|
||||
DB_USERS_NAME: lynxprompt_users
|
||||
DB_BLOG_HOST: postgres
|
||||
DB_BLOG_USER: lynxprompt
|
||||
DB_BLOG_PASSWORD: ${DB_PASSWORD}
|
||||
DB_BLOG_NAME: lynxprompt_blog
|
||||
DB_SUPPORT_HOST: postgres
|
||||
DB_SUPPORT_USER: lynxprompt
|
||||
DB_SUPPORT_PASSWORD: ${DB_PASSWORD}
|
||||
DB_SUPPORT_NAME: lynxprompt_support
|
||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?NEXTAUTH_SECRET must be set}
|
||||
NEXTAUTH_URL: ${APP_URL:?APP_URL must be set}
|
||||
APP_URL: ${APP_URL:?APP_URL must be set}
|
||||
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL:-}
|
||||
# Sign-in is a link sent by mail, so SMTP is needed to log in.
|
||||
SMTP_HOST: ${SMTP_HOST:?SMTP_HOST must be set}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_FROM: ${SMTP_FROM:?SMTP_FROM must be set}
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
pgdata:
|
||||
uploads:
|
||||
+9071
-1781
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user