apx-dokploy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """Dokploy for Action Platform: the `dokploy` deploy target, its overlay, and tools to read what runs there."""
2
+
3
+ from apx_dokploy.plugin import DokployPlugin
4
+
5
+ __all__ = ["DokployPlugin"]
apx_dokploy/cli.py ADDED
@@ -0,0 +1,38 @@
1
+ """`action-platform dokploy …` — the same reads as the tools, from a terminal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import typer
8
+
9
+ from apx_dokploy.settings import connect
10
+ from apx_dokploy.tools import applications_of, deployments_of, projects_of
11
+
12
+ app = typer.Typer(
13
+ help="Dokploy: projects, applications, deployments.", no_args_is_help=True
14
+ )
15
+ options: Any | None = None
16
+
17
+
18
+ @app.command("projects")
19
+ def projects() -> None:
20
+ """List the projects and their environments."""
21
+ for p in projects_of(connect(options)):
22
+ typer.echo(f"{p.name}\t{', '.join(p.environments)}\t{p.id}")
23
+
24
+
25
+ @app.command("applications")
26
+ def applications(project: str = typer.Option("", help="Only this project")) -> None:
27
+ """List the applications, their status and image."""
28
+ for a in applications_of(connect(options), project):
29
+ typer.echo(
30
+ f"{a.project}/{a.environment}/{a.app_name}\t{a.status}\t{a.image or '-'}\t{a.id}"
31
+ )
32
+
33
+
34
+ @app.command("deployments")
35
+ def deployments(application_id: str, limit: int = typer.Option(10)) -> None:
36
+ """Deployment history of one application."""
37
+ for d in deployments_of(connect(options), application_id, limit):
38
+ typer.echo(f"{d.created_at}\t{d.status}\t{d.title or '-'}\t{d.id}")
apx_dokploy/client.py ADDED
@@ -0,0 +1,144 @@
1
+ """The Dokploy REST API (`/api/<router>.<procedure>`, `x-api-key`) and the OCI registry the image lives in — plain urllib, no client library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ import re
8
+ from typing import Any
9
+ from urllib import error, parse, request
10
+
11
+ from action_platform.core.exception import DeployError
12
+
13
+ TIMEOUT = 30
14
+ MANIFEST_TYPES = ", ".join(
15
+ [
16
+ "application/vnd.oci.image.index.v1+json",
17
+ "application/vnd.oci.image.manifest.v1+json",
18
+ "application/vnd.docker.distribution.manifest.list.v2+json",
19
+ "application/vnd.docker.distribution.manifest.v2+json",
20
+ ]
21
+ )
22
+
23
+
24
+ class ApiError(DeployError):
25
+ def __init__(self, status: int, detail: str) -> None:
26
+ super().__init__(f"dokploy answered {status}: {detail}")
27
+ self.status = status
28
+ self.detail = detail
29
+
30
+
31
+ class Dokploy:
32
+ """One Dokploy instance: `get` and `post` against its API with the key."""
33
+
34
+ def __init__(self, url: str, api_key: str) -> None:
35
+ self.url = url.rstrip("/")
36
+ self.api_key = api_key
37
+
38
+ def get(self, procedure: str, **query: Any) -> Any:
39
+ target = f"{self.url}/api/{procedure}"
40
+
41
+ if query:
42
+ target += "?" + parse.urlencode(query)
43
+
44
+ return self._send("GET", target, None)
45
+
46
+ def post(self, procedure: str, body: dict[str, Any] | None = None) -> Any:
47
+ return self._send("POST", f"{self.url}/api/{procedure}", body or {})
48
+
49
+ def _send(self, method: str, target: str, body: dict[str, Any] | None) -> Any:
50
+ data = json.dumps(body).encode() if body is not None else None
51
+ req = request.Request(target, data=data, method=method)
52
+ req.add_header("x-api-key", self.api_key)
53
+ req.add_header("accept", "application/json")
54
+
55
+ if data is not None:
56
+ req.add_header("content-type", "application/json")
57
+
58
+ try:
59
+ with request.urlopen(req, timeout=TIMEOUT) as response:
60
+ text = response.read().decode(errors="replace")
61
+ except error.HTTPError as e:
62
+ raise ApiError(e.code, e.read().decode(errors="replace")[:500]) from e
63
+ except (error.URLError, TimeoutError) as e:
64
+ raise DeployError(f"dokploy unreachable at {self.url}: {e}") from e
65
+
66
+ return json.loads(text) if text.strip() else None
67
+
68
+
69
+ def image_exists(
70
+ image: str,
71
+ tag: str,
72
+ username: str | None = None,
73
+ password: str | None = None,
74
+ ) -> bool:
75
+ """Whether `image:tag` is in its registry — a manifest HEAD, with the bearer token the registry asks for when it asks for one."""
76
+ registry, name = _split(image)
77
+ target = f"https://{registry}/v2/{name}/manifests/{tag}"
78
+ basic = (
79
+ base64.b64encode(f"{username}:{password}".encode()).decode()
80
+ if username and password
81
+ else None
82
+ )
83
+ status, challenge = _head(target, "Basic " + basic if basic else None)
84
+
85
+ if status == 401 and challenge:
86
+ token = _token(challenge, basic)
87
+ status, _ = _head(target, f"Bearer {token}")
88
+
89
+ if status == 200:
90
+ return True
91
+
92
+ if status == 404:
93
+ return False
94
+
95
+ raise DeployError(f"registry {registry} answered {status} for {name}:{tag}")
96
+
97
+
98
+ def _split(image: str) -> tuple[str, str]:
99
+ head, _, rest = image.partition("/")
100
+
101
+ if rest and ("." in head or ":" in head or head == "localhost"):
102
+ return head, rest
103
+
104
+ name = image if "/" in image else f"library/{image}"
105
+
106
+ return "registry-1.docker.io", name
107
+
108
+
109
+ def _head(target: str, authorization: str | None) -> tuple[int, str]:
110
+ req = request.Request(target, method="HEAD")
111
+ req.add_header("accept", MANIFEST_TYPES)
112
+
113
+ if authorization:
114
+ req.add_header("authorization", authorization)
115
+
116
+ try:
117
+ with request.urlopen(req, timeout=TIMEOUT) as response:
118
+ return response.status, ""
119
+ except error.HTTPError as e:
120
+ return e.code, e.headers.get("www-authenticate", "")
121
+ except (error.URLError, TimeoutError) as e:
122
+ raise DeployError(f"registry unreachable: {e}") from e
123
+
124
+
125
+ def _token(challenge: str, basic: str | None) -> str:
126
+ fields = dict(re.findall(r'(\w+)="([^"]*)"', challenge))
127
+ realm = fields.get("realm")
128
+
129
+ if not realm:
130
+ raise DeployError(f"registry challenge not understood: {challenge}")
131
+
132
+ query = {k: v for k, v in fields.items() if k in ("service", "scope")}
133
+ req = request.Request(f"{realm}?{parse.urlencode(query)}")
134
+
135
+ if basic:
136
+ req.add_header("authorization", f"Basic {basic}")
137
+
138
+ try:
139
+ with request.urlopen(req, timeout=TIMEOUT) as response:
140
+ data = json.loads(response.read().decode())
141
+ except error.HTTPError as e:
142
+ raise DeployError(f"registry token refused ({e.code}): check the credentials")
143
+
144
+ return data.get("token") or data.get("access_token") or ""
@@ -0,0 +1,14 @@
1
+ {
2
+ "project_name": "My Project",
3
+ "project_slug": "{{ cookiecutter.project_name|lower|replace(' ', '-') }}",
4
+ "github_owner": "actionplatform",
5
+ "package_name": "{{ cookiecutter.project_slug|replace('-', '_') }}",
6
+ "language": ["python", "node", "go", "java", "kotlin", "ruby"],
7
+ "type": ["web"],
8
+ "ci": ["github", "gitlab", "jenkins"],
9
+ "_kind": "cloud",
10
+ "_cloud": "dokploy",
11
+ "_description": "Docker image published by the CI on every release, run by Dokploy",
12
+ "_types": ["web"],
13
+ "_copy_without_render": [".github/workflows/docker-publish.yml"]
14
+ }
@@ -0,0 +1,9 @@
1
+ """Cloud overlay post-gen: keep the image workflow only for the CI it belongs to."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ CI = "{{ cookiecutter.ci }}"
7
+
8
+ if CI != "github" and Path(".github").exists():
9
+ shutil.rmtree(".github")
@@ -0,0 +1,11 @@
1
+ .git/
2
+ .github/
3
+ .venv/
4
+ node_modules/
5
+ __pycache__/
6
+ *.pyc
7
+ .env
8
+ tests/
9
+ docs/
10
+ build/
11
+ dist/
@@ -0,0 +1,52 @@
1
+ name: Package Docker
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+ packages: write
11
+
12
+ concurrency:
13
+ group: image-${{ github.ref_name }}
14
+ cancel-in-progress: false
15
+
16
+ jobs:
17
+ build:
18
+ name: Build and push ${{ github.ref_name }}
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - name: Read the version from the tag
24
+ id: version
25
+ run: |
26
+ tag="${GITHUB_REF_NAME}"
27
+ version="${tag#v}"
28
+ [ "$version" = "$tag" ] && version="$(cat LAST_VERSION)"
29
+ echo "version=$version" >> "$GITHUB_OUTPUT"
30
+ echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
31
+
32
+ - uses: docker/setup-qemu-action@v3
33
+ - uses: docker/setup-buildx-action@v3
34
+
35
+ - uses: docker/login-action@v3
36
+ with:
37
+ registry: ghcr.io
38
+ username: ${{ github.actor }}
39
+ password: ${{ secrets.GITHUB_TOKEN }}
40
+
41
+ - uses: docker/build-push-action@v6
42
+ with:
43
+ context: .
44
+ platforms: linux/amd64,linux/arm64
45
+ push: true
46
+ tags: |
47
+ ${{ steps.version.outputs.image }}:${{ steps.version.outputs.version }}
48
+ ${{ steps.version.outputs.image }}:latest
49
+ cache-from: type=gha
50
+ cache-to: type=gha,mode=max
51
+
52
+ - run: echo "${{ steps.version.outputs.image }}:${{ steps.version.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
@@ -0,0 +1,42 @@
1
+ # Deploy — Dokploy
2
+
3
+ Overlay `cloud/dokploy`. The CI builds the image on every release and Dokploy pulls it; the platform tells Dokploy which tag to run.
4
+
5
+ | | |
6
+ |---|---|
7
+ | Image | `ghcr.io/{{ cookiecutter.github_owner }}/{{ cookiecutter.project_slug }}:<version>` — `.github/workflows/docker-publish.yml`, on every published release |
8
+ | Dokploy project | `{{ cookiecutter.project_slug }}` |
9
+ | Dokploy environment | one per scope: `dev`, `prod` |
10
+ | Application | `{{ cookiecutter.project_slug }}-<scope>`, listens on port 8000 |
11
+
12
+ ## Set up once
13
+
14
+ 1. In Dokploy, Settings → API Keys: create a key. Put it and the instance url in the plugin's options (Plugins → Dokploy → Configure on the platform; `DOKPLOY_URL` and `DOKPLOY_API_KEY` on a machine).
15
+ 2. Is the GitHub package private? Give the plugin a registry username and a token with `read:packages`, or make the package public under the repository's Packages settings.
16
+ 3. `platform.toml`:
17
+
18
+ ```toml
19
+ [deploy]
20
+ target = "dokploy"
21
+
22
+ [deploy.domains]
23
+ prod = "{{ cookiecutter.project_slug }}.example.com"
24
+ ```
25
+
26
+ The first deploy creates the project, the environment, the application and the domain (Let's Encrypt). Point the DNS record at the Dokploy server before that.
27
+
28
+ ## Every release
29
+
30
+ ```bash
31
+ action-platform release minor # tags vX.Y.Z; the workflow publishes the image
32
+ action-platform deploy --dry-run # readiness: settings, image published, key accepted, application state
33
+ action-platform deploy # saveDockerProvider(<image>:<version>) + deploy, followed to done
34
+ ```
35
+
36
+ `rollback` redeploys the previous version from the application's deployment history; `diagnose` reports the status, the image and the url.
37
+
38
+ ## Locally
39
+
40
+ ```bash
41
+ docker compose up --build # http://localhost:8000
42
+ ```
@@ -0,0 +1,26 @@
1
+ # Two stages, the same shape for every language: the build image assembles the app with ap-build,
2
+ # the runtime image runs it. The app serves HTTP on $PORT.
3
+ {%- set lang = "java" if cookiecutter.language in ["java", "kotlin"] else cookiecutter.language %}
4
+ FROM ghcr.io/actionplatform/build-{{ lang }}:0 AS build
5
+ ARG TARGETARCH
6
+ WORKDIR /w
7
+ COPY . .
8
+ RUN AP_ARCH=$([ "$TARGETARCH" = "amd64" ] && echo x86_64 || echo arm64) AP_ARTIFACTS=/out ap-build package
9
+
10
+ FROM ghcr.io/actionplatform/runtime-{{ lang }}:0
11
+ WORKDIR /app
12
+ COPY --from=build /out /app
13
+ ENV PORT=8000
14
+ EXPOSE 8000
15
+ {%- if cookiecutter.language == "python" %}
16
+ ENV PYTHONPATH=/app
17
+ CMD ["-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
18
+ {%- elif cookiecutter.language == "node" %}
19
+ CMD ["dist/server.js"]
20
+ {%- elif cookiecutter.language == "go" %}
21
+ ENTRYPOINT ["/app/bootstrap"]
22
+ {%- elif cookiecutter.language == "ruby" %}
23
+ CMD ["bundle", "exec", "rackup", "-s", "webrick", "-o", "0.0.0.0", "-p", "8000"]
24
+ {%- else %}
25
+ CMD ["-jar", "/app/app.jar", "--server.port=8000"]
26
+ {%- endif %}
@@ -0,0 +1,9 @@
1
+ services:
2
+ app:
3
+ build: .
4
+ image: {{ cookiecutter.project_slug }}:local
5
+ ports:
6
+ - "8000:8000"
7
+ environment:
8
+ SCOPE: development
9
+ restart: unless-stopped
@@ -0,0 +1,10 @@
1
+ {
2
+ "clouds": [
3
+ {
4
+ "id": "dokploy",
5
+ "description": "Docker image published by the CI on every release, run by Dokploy",
6
+ "types": ["web"],
7
+ "languages": ["python", "node", "go", "java", "kotlin", "ruby"]
8
+ }
9
+ ]
10
+ }
apx_dokploy/plugin.py ADDED
@@ -0,0 +1,64 @@
1
+ """The plugin: the dokploy overlay, `action-platform dokploy` commands, `dokploy_*` tools, and the options an organization fills in."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from action_platform.abc import Option, Plugin, Surface
8
+
9
+ from apx_dokploy import cli
10
+ from apx_dokploy.tools import register_tools
11
+
12
+
13
+ class DokployPlugin(Plugin):
14
+ slug = "dokploy"
15
+ name = "Dokploy"
16
+ description = (
17
+ "Deploy a Docker image the CI publishes to a Dokploy instance; "
18
+ "read its projects, applications and deployments"
19
+ )
20
+ min_core = "0.28.1"
21
+ needs = [
22
+ "env: DOKPLOY_URL, DOKPLOY_API_KEY on a machine (the plugin's options on the platform)",
23
+ "net: the Dokploy instance's url; the image registry (ghcr.io by default)",
24
+ ]
25
+ options = [
26
+ Option(
27
+ "url",
28
+ "Dokploy URL",
29
+ "url",
30
+ help="Where the Dokploy dashboard answers, https://dokploy.example.com. A project may override it with [deploy] url.",
31
+ required=True,
32
+ ),
33
+ Option(
34
+ "api_key",
35
+ "API key",
36
+ "secret",
37
+ help="Generated under Settings > API Keys in Dokploy. Deploys, creates and deletes applications on your behalf; never written to a repository.",
38
+ required=True,
39
+ ),
40
+ Option(
41
+ "registry_username",
42
+ "Registry username",
43
+ "text",
44
+ help="Only for a private image registry: the user Dokploy pulls with. Empty for a public image.",
45
+ ),
46
+ Option(
47
+ "registry_password",
48
+ "Registry password",
49
+ "secret",
50
+ help="The token that goes with the registry username (a GitHub token with read:packages for ghcr.io).",
51
+ ),
52
+ ]
53
+
54
+ @property
55
+ def overlays(self) -> Path:
56
+ return Path(__file__).parent / "overlays"
57
+
58
+ def register(self, surface: Surface) -> None:
59
+ if surface.mcp is not None:
60
+ register_tools(surface.mcp, surface.options)
61
+
62
+ if surface.cli is not None:
63
+ cli.options = surface.options
64
+ surface.cli.add_typer(cli.app, name="dokploy")
@@ -0,0 +1,46 @@
1
+ """Where a value comes from, in order: the target's own argument, what the deploy job carries (`AP_DOKPLOY_<KEY>` in `ctx.env`), the process environment (`AP_DOKPLOY_<KEY>`, then the plain name such as `DOKPLOY_URL`), the plugin's options file on this machine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ from action_platform.core.exception import ActionPlatformError
9
+ from action_platform.plugins.options import FileOptions
10
+
11
+ from apx_dokploy.client import Dokploy
12
+
13
+ SLUG = "dokploy"
14
+ PLAIN = {"url": "DOKPLOY_URL", "api_key": "DOKPLOY_API_KEY"}
15
+
16
+
17
+ def setting(
18
+ key: str,
19
+ explicit: str | None = None,
20
+ env: dict[str, str] | None = None,
21
+ options: Any | None = None,
22
+ ) -> str | None:
23
+ name = f"AP_{SLUG}_{key}".upper()
24
+ stored = (options or FileOptions(SLUG)).get(key)
25
+ found = (
26
+ explicit
27
+ or (env or {}).get(name)
28
+ or os.environ.get(name)
29
+ or os.environ.get(PLAIN.get(key, ""))
30
+ or stored
31
+ )
32
+
33
+ return str(found) if found else None
34
+
35
+
36
+ def connect(options: Any | None = None) -> Dokploy:
37
+ url = setting("url", options=options)
38
+ key = setting("api_key", options=options)
39
+
40
+ if not url or not key:
41
+ raise ActionPlatformError(
42
+ "dokploy: set the plugin's url and api_key options on the platform, "
43
+ "or DOKPLOY_URL and DOKPLOY_API_KEY on a machine"
44
+ )
45
+
46
+ return Dokploy(url, key)