diff --git a/README.md b/README.md
index 4c9e039..2162e53 100644
--- a/README.md
+++ b/README.md
@@ -2,45 +2,64 @@
[](https://github.com/softwarity/plug/releases)
[](https://hub.docker.com/r/softwarity/plug)
-[](https://hub.docker.com/r/softwarity/plug)
[](LICENSE)
-[](https://hub.docker.com/r/softwarity/plug/tags)
[](https://github.com/softwarity/plug/releases)
[](https://github.com/softwarity/plug/actions/workflows/ci.yml)
-[](https://github.com/sponsors/softwarity)
-Run a local process as a member of your cluster: it resolves cluster service
-names, reaches cluster services, and is itself reachable in the cluster under a
-name - with no code change and no proxy settings in your app.
+**plug runs a local process as a member of your cluster: it resolves the
+cluster's service names, reaches its services, and is reachable under a name of
+its own, with no change to the application.**
+
+A process on your machine cannot see the cluster it is written for. Reaching
+it means port-forwards, `localhost` mappings and per-environment URLs, and
+nothing in the cluster can call it back. plug removes that gap for the life of
+one command.
```bash
plug -s my-app:8080:3000 npm run start:dev
-# reaches cluster services by name - and is itself reachable in the cluster
-# as my-app:8080, forwarded to its local :3000
+# the dev server reaches cluster services by name (http://orders:8080, mongodb:27017)
+# and the cluster reaches it as my-app:8080, forwarded to its local :3000
```
-Prefix any command with `plug` and it joins the cluster by name - Node, the
-JVM, Python, Go, curl, gRPC, database drivers, anything. Stop the command and
-your machine is exactly as it was.
-
-[](https://softwarity.github.io/plug/)
+> **Before you deploy it.** By default the agent lets in whoever reaches its
+> port. On Docker, Compose and Swarm it needs the Docker socket, which is root
+> on the host. The CLI installs over SSH from the agent. Use plug on dev or
+> staging clusters you trust, never on a public network.
+> [Details](#security).
## What you get
-- Reach cluster services by their real names, from your laptop - no port-forwards
- to wire up, no `localhost:PORT` mappings, no `/etc/hosts` edits.
-- Be reachable in the cluster under your own name - workloads call `my-app:8080`
- and land on your local process, for the life of the session.
-- Works with any language or tool, unchanged - your app's sockets are never touched.
-- Runs on Linux, macOS and Windows.
-- Several clusters at once, side by side.
-- Set up once per cluster, then no sudo or admin for daily use.
+- Cluster services by their real names, from your machine: no port-forward, no
+ `/etc/hosts` edit, no proxy setting.
+- A name of your own in the cluster: workloads call `my-app:8080` and reach your
+ process. A deployed service with that name is set aside for the session and
+ restored when it ends.
+- On that takeover, the workload's environment and volumes on your machine.
+- Any language or tool, unchanged: plug works at the IP layer, not in your
+ process.
+- Linux, macOS and Windows; several clusters at once.
+- A privilege granted once at install; no sudo or admin for daily use.
+
+## Supported platforms
+
+| Cluster | Agent deployment | How plug publishes your name |
+|---|---|---|
+| Docker Compose | a service in your stack | a signpost container, through the Docker socket |
+| Docker Swarm | a service in your stack, on a manager | a signpost service, through the Docker socket |
+| Kubernetes | [`deploy/plug-k8s.yaml`](deploy/plug-k8s.yaml) | a Service, through a namespace-scoped Role |
+| OpenShift / OKD | the same manifest, `restricted-v2` SCC | a Service, through the same Role |
+
+Compose, Swarm and Kubernetes run end to end in CI on every push, from Linux,
+macOS and Windows clients; OpenShift runs on MicroShift built from OKD. What is
+proven where, and what is not yet: the
+[coverage matrix](https://plug.softwarity.dev/coverage/).
## Install
-Two pieces: a small agent in the cluster, and the `plug` CLI on each dev machine.
+Two pieces: an agent in the cluster, and the `plug` CLI on each machine.
-**In the cluster** - add the agent to the stack you want to reach:
+**In the cluster.** On Compose or Swarm, add the agent to the stack you want
+to reach:
```yaml
services:
@@ -59,17 +78,10 @@ services:
constraints: [node.role == manager]
```
-The socket line is **required** on Docker, Compose and Swarm: it is how the
-agent creates your `-s` name. It is root on the host, so mount it only on a
-cluster you trust. Left as is, the agent lets in whoever reaches its port; to let
-in only the developers a member accepts, set `PLUG_ADMISSION: invite` on it (see
-[Security](#security)).
-Kubernetes needs no socket: the bundled manifest grants a namespace-scoped RBAC
-role instead - see [below](#the-name-in-the-cluster).
-
-Standalone agent, or Kubernetes: see the [documentation](https://softwarity.github.io/plug/).
+On Kubernetes and OpenShift, apply [`deploy/plug-k8s.yaml`](deploy/plug-k8s.yaml).
+Every platform, step by step: [Install](https://plug.softwarity.dev/install/).
-**On your machine** - install straight from the cluster, in one line.
+**On your machine.** Install from the cluster, in one line.
Linux and macOS:
@@ -85,292 +97,51 @@ ssh -n -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null get@$
| bash -s -- $cluster 2222
```
-The install prepares your machine once - it may ask for your password (or, on
-Windows, to run as Administrator) a single time - so that every later `plug` run
-needs no privilege. After that you are ready.
-
-The cluster is saved as a profile named after its host. To name it yourself,
-the name you will type after `-p`, add it to the command: `install my-cluster`
-on Linux and macOS, `bash -s -- $cluster 2222 my-cluster` on Windows.
-
-## Use
-
-```bash
-plug -s my-app:8080:3000 npm run start:dev
-plug -s my-api:8080:8080 ./mvnw spring-boot:run
-```
-
-`-s name:cluster-port:local-port` is the name your process answers to in the
-cluster; the same session reaches cluster services by name in return. It is
-**required**: in a cluster a running process is a service, and a service has a
-name - so name yours, even when nothing calls it back yet. The first run asks
-which cluster to use and remembers it. Reaching another cluster is just naming it:
-
-```bash
-plug -p staging -s my-app:8080:3000 npm run start:dev # asks once, then remembered
-```
-
-Everyday commands: `plug ls` (list clusters), `plug test` (check one is
-reachable), `plug rn` / `plug rm` (rename / remove), `plug prune` (delete the
-cached cores no cluster runs any more), `plug uninstall`, `plug about`.
-
-## Several clusters at once
-
-Run the same process against two clusters in parallel - each stays isolated:
-
-```bash
-plug -p prod -s my-app:8080:3000 npm run start
-plug -p staging -s my-app:8080:3000 npm run start
-```
-
-Supported on all three OSes - proven simultaneously in CI on Linux, macOS and
-Windows. See the [coverage matrix](https://softwarity.github.io/plug/#/coverage)
-for the details.
-
-## The name in the cluster
-
-`-s name:cluster-port:local-port` publishes `name` in the cluster and forwards
-`name:cluster-port` to your machine's `local-port`, for the lifetime of the
-session - **no name pre-declared, no redeploy**. Any workload calling
-`http://name:cluster-port` lands on your process. The agent creates the name on
-the fly, which it does per engine:
-
-- **Docker / Compose** - mount the Docker socket on the agent (required). Each
- `-s` spins up a tiny *signpost* container carrying the DNS alias, removed with
- the session.
-- **Swarm** - same socket; the signpost is a Swarm *service*, which joins the
- stack's overlay whether or not it is `attachable`, so **no network change**.
- The agent just needs to run on a **manager** node (to create services).
-- **Kubernetes** - no socket: the bundled [manifest](deploy/plug-k8s.yaml) grants
- a namespace-scoped RBAC role (Services and their endpoints, the EndpointSlice a
- takeover must clear, the agent's own Deployment for `plug update`, pods and
- `pods/exec` for the environment and the live mount) - every rule with its reason
- in the manifest and on the [Kubernetes page](https://softwarity.github.io/plug/kubernetes).
- **Re-apply it when you upgrade the agent**: `plug update` moves the image, never
- the manifest, and `plug doctor` names any rule a newer agent is missing.
-- **OpenShift and OKD** - the same manifest, admitted by the default
- `restricted-v2` SCC with nothing to grant: the agent runs as the uid the cluster
- picks, never root. Every commit runs it on MicroShift built from OKD.
-- **No socket, no RBAC** - the agent **refuses to start**, naming what is
- missing. Provisioning is the feature; an agent that cannot do it is a
- deployment mistake, not a degraded mode.
+The install may ask for your password (or, on Windows, to run as
+Administrator), once. The cluster is saved as a profile named after its host; add a name to
+the command to choose your own (`install my-cluster`, or
+`bash -s -- $cluster 2222 my-cluster` on Windows).
-```yaml
-services:
- plug:
- image: docker.io/softwarity/plug:latest
- ports: ["2222:22"]
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock # required: the agent creates your -s name
-```
-
-The Docker socket is root on the host - mount it only on a cluster you trust.
-plug verifies the
-full path at startup so a missing name fails loud, and the port closes with the
-session.
-
-Developing a service that is **already deployed** in the stack? Its name is
-taken - so plug **takes it over**: the deployed workload is parked for the
-session (containers stopped, Swarm service scaled to 0, Kubernetes Service
-repointed) and **restored when the session ends**, replica count included,
-even across an agent restart - and even when the session never gets to say
-so: a laptop closed, a network gone at the moment of Ctrl-C. The agent checks
-every minute whether the session holding a parked workload still answers, and
-puts the workload back when it does not. Your local process answers the name
-in its place, and **inherits its environment**: the variables the deployed
-workload runs with, secrets included as the pod already had them, are handed
-to your command - **the cluster's value wins** over whatever the shell or a
-`.env` left inherited, so the process behaves as it does in the cluster;
-`--no-env=KEY` holds a key back to your own value (a test database), and a bare
-`--no-env` opts out entirely. No `.env` to retype from the
-deployment. `--env-of ` takes another workload's instead, without
-parking it: a `-c` one-off script run with a service's own credentials, or a
-`-s` that borrows the variables of the service it talks to. Afterwards the cluster is exactly as it was. A name held by
-another live plug session is still refused.
-
-Its **data** too: the workload's volumes - a Docker volume, a bind, a
-PersistentVolumeClaim - are **mounted on your machine, live and read-write**,
-for the session, by default: each under the session's temp directory at its
-cluster path, the variables naming it repointed, so the process finds its data
-where its environment says. `--no-mount` opts out; `--mount /data` puts one at
-the exact path instead (`--mount api:/data:/srv/data` names the workload).
-Nothing to install: the agent starts a helper beside the workload that serves
-the volume over SMB, and plug mounts it through the tunnel with the client your
-OS already has (`mount_smbfs` on macOS, the cifs module on Linux, the SMB
-redirector on Windows, where it lands on a drive letter). Files land as the volume's owner. A session that dies has its helper
-reaped by the agent within the minute and its mount cleaned by the next run or
-`plug doctor --fix`.
-
-## Let plug pick the local port
-
-The cluster port is agreed in advance - it is what other workloads dial. The
-**local** one is nobody's business but yours, and pinning it is what makes two
-projects fight over `3000`, or the same app refuse to run on two branches at
-once. Name it instead, and plug picks a free one per session:
+Then:
```bash
-plug -s web:8080:PORT npm run dev -- --port={PORT}
+plug -s my-app:8080:3000 npm run start:dev # a service the cluster can call
+plug -c npm test # a pure client of the cluster
```
-`PORT` declares (bare - the third field of a `-s` can only ever be a port, so
-there is nothing to disambiguate), `{PORT}` references it in the command
-(braced - argv is free text, and a bare `PORT` would also rewrite
-`--transport=PORTAL`). Any name works. The mapping is armed on that same
-number, so the cluster reaches `web:8080` whichever port your process landed on
-today.
-
-The command line is the only channel - plug puts nothing in your process's
-environment. One number, one way to hand it over, and no variable of yours
-quietly overwritten.
-
-Some things this makes easy:
-
-```bash
-# Two branches of the same service, side by side, both answering in the cluster
-git worktree add ../hotfix && cd ../hotfix
-plug -s api:8080:PORT ./mvnw spring-boot:run -Dserver.port={PORT}
-
-# One process, two cluster names, one listener - the same name means one port
-plug -s web:80:PORT -s web-tls:443:PORT node server.js --listen={PORT}
-
-# A shared CI runner, where a pinned port is a race against the other jobs
-plug -s e2e:8080:PORT npm run serve -- --port={PORT}
-```
-
-The two halves have to match, and plug says so at startup rather than let either
-mistake through - both fail silently otherwise, from opposite ends:
-
-- a `{TOKEN}` nothing declared reaches your command as the literal string
- `{PROT}`, which either crashes it or makes it fall back to a default port the
- cluster is not forwarding to;
-- a name nothing references allocates a port your process is never told about -
- the cluster name gets published, and nothing ever answers it.
-
-Commands that use braces for their own purposes (`awk '{print}'`) are untouched
-when no `-s` names a port.
-
-Pinning still works, and still makes sense when something outside the session
-needs a stable address (a bookmarked URL, a debugger attach config).
-
-Needs plug ≥ 2.4 on both sides: your launcher checks the mapping before it
-connects, and the cluster's own core is what resolves it. `plug update` aligns
-the two.
-
-## Move a cluster to another version
-
-`plug update` follows the tag the deployment already carries. Name a tag to
-switch the channel it follows:
-
-```bash
-plug -p neo update tag # the newest release published
-plug -p neo update latest # the latest stream
-plug -p neo update feat-09 # a branch's tag, at whatever it points to now
-plug -p neo update 2.3.0 # an exact release - downgrades included
-```
-
-The tag is checked against the registry before anything is repointed: aiming a
-deployment at a tag nobody published leaves an agent that cannot pull. Release
-and stream are told apart by the tag itself - `x`, `x.y` or `x.y.z` is a
-release, anything else moves under you.
-
-### Being told a version is there
-
-A session checks once a day, in the background, whether the registry carries a
-release your agent does not, and says so the next time you run plug:
-
-```bash
-plug config -p neo # show that cluster's policy
-plug config -p neo update=notify # default: tell you, and offer to apply
-plug config -p local update=auto # apply it, on a cluster you govern
-plug config -p shared update=none # never look, on one you do not
-```
-
-`notify` asks, when there is a real terminal to ask in: it says what applying
-costs - rolling the cluster's agent, so every other session on it reconnects -
-the default is no, and it gives up after a few seconds. A script or a CI job
-sees the message and never a question.
-
-The policy belongs to the **profile**, not to the machine, because `auto`
-updates the **agent** - and an agent is shared. You may well govern your own
-local cluster and have no say over the shared one, so each names its own rule.
-
-`auto` rolls that agent: every session on that cluster drops and reconnects by
-itself, and each one says on the way back that it is now running the older core.
-Nothing local is replaced under a running command - a session keeps the version
-it started with, by design, because the core is holding your process. The new one
-is picked up the next time you launch.
-
-A deployment following a moving tag (`latest`, a branch) is checked too, on
-bytes rather than on a number: there is no version to compare - `latest` is
-always called `latest` - so plug asks the registry what that tag points at today
-and compares it with the digest the agent reports. Different digest, different
-image, and it says so.
-
-When the registry cannot be reached from your machine - a corporate proxy, a VPN
-that splits routes - plug asks the cluster instead, which pulls from that
-registry for a living. An agent too old to know the question stays silent, as
-before.
-
-## Which DNS servers plug forwards to
-
-Names that are not cluster services are forwarded to the nameservers this machine
-already used, read from the system: `scutil` on macOS, the child's former
-`resolv.conf` on Linux, the adapter table on Windows. Where several are
-configured, they are ranked the way the OS ranks its interfaces - so a corporate
-VPN's resolver, the only one that knows your internal names, comes first.
-
-They are not a startup fact. Connect a VPN mid-session, drop it, or move to
-another network, and plug follows - including the case macOS makes invisible,
-where changing Wi-Fi network moves the resolvers without moving the network
-service. `plug doctor` reports where lookups are going right now:
-
-```
-✓ dns forwarding forwarding dotted names to 10.8.0.1:53, 192.168.1.1:53
-```
-
-With none found, plug falls back to a public resolver and says so. That keeps
-public names working, but internal ones will not resolve and those lookups leave
-your network, so the message is worth reading rather than dismissing.
+From an empty cluster to a first run:
+[Getting started](https://plug.softwarity.dev/getting-started/).
## Limits
-It carries TCP reached by name. UDP, QUIC and ping are not tunnelled (most
-clients fall back to TCP), and a hard-coded IPv6 literal is not either - a
-service reached by name is always fine.
+- TCP to a name. UDP, QUIC and ping are not carried, nor a hard-coded IPv6
+ literal.
+- One process serves a name at a time: a second session asking for a name in
+ use is refused, and told where it is held from.
+- Once in, every session can do the same things. Per-person rights need a
+ gateway that embeds the agent, such as Meerkat (below).
## Security
-Who may open a session on a cluster is decided in one of three ways:
-
-- **Open**, the default: whoever reaches the agent's port gets in. It fits a dev
- cluster on a network you trust, where a colleague is at work a minute after the
- install, with no key to hand out and no account to create.
-- **By approval** (`PLUG_ADMISSION=invite`): only the keys a member accepted get
- in, the first key to connect being the first member. A stranger who reaches the
- port gets a request in a queue, and nothing else.
-- **Hosted**, with [Meerkat](https://softwarity.github.io/plug/#/meerkat): the
- people a gateway knows, each under their own name and with their own rights.
-
-Whatever the mode, never expose the agent's port on a public network, and deploy
-it only on a dev cluster: once in, a session reaches what the agent reaches. The
-full model, and how to move from one mode to another, is in the
-[documentation](https://softwarity.github.io/plug/#/security).
-
-The CLI binaries a release publishes are signed, and plug verifies that signature
-before running the core with privilege: the agent tells plug which bytes to run,
-and a digest announced by that same agent proves only that the download was not
-corrupted. Maintainers: the key and its rotation are in [RELEASING.md](RELEASING.md).
-
-## Documentation
-
-Everything else - how it works, deployment on Swarm, Kubernetes and OpenShift, profiles and
-versions, the security model, and the per-OS coverage matrix:
-
-**https://softwarity.github.io/plug/**
-
-Build from source with `go build -o plug ./cli`. Distribution is from the cluster
-only - the agent image is the single source of the CLI, so there is no separate
-package to install or keep in sync.
+- **Who gets in.** Open by default: the agent accepts whoever reaches its port,
+ with a key shipped in every plug binary, which proves the software and not a
+ person. With `PLUG_ADMISSION=invite`, only the keys a member accepted get in.
+ Through a gateway that embeds the agent, each developer is a named identity.
+- **What a session reaches.** The networks the agent is attached to; for any
+ workload in reach, its environment, its mounted secret and config files, its
+ volumes, and its name. Attach the agent only where developers need it, never
+ to production workloads.
+- **The Docker socket** (Docker, Compose, Swarm) is root on the host: mount it
+ only on a cluster you trust. Kubernetes and OpenShift use a namespace-scoped
+ Role instead.
+- **The install** runs a script served by the agent over SSH, without checking
+ the agent's host key; later connections pin it. The CLI binaries are signed,
+ and plug verifies the signature before granting itself privilege.
+
+Appropriate for dev and staging clusters on a network you trust. Never expose
+the agent's port publicly. The full model:
+[Security](https://plug.softwarity.dev/security/). Maintainers: signing key and
+rotation in [RELEASING.md](RELEASING.md).
## License
@@ -385,12 +156,16 @@ available - contact **[francois@hhdev.fr](mailto:francois@hhdev.fr)**.
Third-party components are listed in
[THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md).
-## Sponsor
+## Documentation
+
+**https://plug.softwarity.dev**: getting started, install per platform, how it
+works, the CLI, security, the comparison with other tools, troubleshooting.
+
+Build from source with `go build -o plug ./cli`. The agent image is the only
+source of the CLI: developers install it from their cluster.
-plug is free and there is no paid tier planned. Nothing in it is gated behind
-sponsorship and nothing will be. If it saves you the afternoon it was built to
-save, you can [sponsor its development](https://github.com/sponsors/softwarity),
-one-off or monthly.
+plug is free and has no paid tier. To support its development:
+[GitHub Sponsors](https://github.com/sponsors/softwarity).
-That is a different thing from the licence above: a company whose use falls
-outside FSL needs a commercial licence, not a donation.
+[Meerkat](https://softwarity.github.io/meerkat/), an app gateway by the same
+team, embeds the plug agent and ties each session to a named developer.
diff --git a/docs/.gitignore b/docs/.gitignore
index 6ada58b..51ab905 100644
--- a/docs/.gitignore
+++ b/docs/.gitignore
@@ -7,6 +7,8 @@
/src/assets/plug-stack.yml
/src/assets/plug-k8s.yaml
/src/assets/plug-service.yml
+# generated at build by scripts/gen-sitemap.mjs (the pages of app.routes.ts)
+/src/root/sitemap.xml
*.log
.DS_Store
/.frames
diff --git a/docs/angular.json b/docs/angular.json
index 3d550b9..7467133 100644
--- a/docs/angular.json
+++ b/docs/angular.json
@@ -18,7 +18,7 @@
"polyfills": [],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
- "assets": ["src/assets"],
+ "assets": ["src/assets", { "glob": "*", "input": "src/root", "output": "/" }],
"styles": ["src/styles.scss"],
"scripts": [],
"allowedCommonJsDependencies": [
@@ -28,9 +28,8 @@
"prismjs/components/prism-typescript",
"prismjs/components/prism-yaml"
],
- "security": {
- "autoCsp": true
- }
+ "server": "src/main.server.ts",
+ "outputMode": "static"
},
"configurations": {
"production": {
@@ -41,7 +40,12 @@
"maximumError": "1.5mb"
}
],
- "outputHashing": "all"
+ "outputHashing": "all",
+ "optimization": {
+ "scripts": true,
+ "styles": { "minify": true, "inlineCritical": false },
+ "fonts": true
+ }
},
"development": {
"optimization": false,
diff --git a/docs/package-lock.json b/docs/package-lock.json
index 63698a9..3e2471e 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -15,7 +15,9 @@
"@angular/forms": "^22.2.3",
"@angular/material": "^22.2.3",
"@angular/platform-browser": "^22.2.3",
+ "@angular/platform-server": "^22.2.3",
"@angular/router": "^22.2.3",
+ "@angular/ssr": "^22.2.3",
"@softwarity/projects": "^1.0.30",
"prismjs": "^1.30.0",
"rxjs": "~7.8.0",
@@ -1081,6 +1083,26 @@
}
}
},
+ "node_modules/@angular/platform-server": {
+ "version": "22.2.3",
+ "resolved": "https://registry.npmjs.org/@angular/platform-server/-/platform-server-22.2.3.tgz",
+ "integrity": "sha512-ROuJklv7iQVI1zIulo4MBDnk3Z0ZzihBWuN4nxFY8MTWxVMk97p92j1Wuyt4Kmx2kb5PB+kpcR1Jl6e13idftA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.3.0",
+ "xhr2": "^0.2.0"
+ },
+ "engines": {
+ "node": "^22.22.3 || ^24.15.0 || >=26.0.0"
+ },
+ "peerDependencies": {
+ "@angular/common": "22.2.3",
+ "@angular/compiler": "22.2.3",
+ "@angular/core": "22.2.3",
+ "@angular/platform-browser": "22.2.3",
+ "rxjs": "^6.5.3 || ^7.4.0"
+ }
+ },
"node_modules/@angular/router": {
"version": "22.2.3",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-22.2.3.tgz",
@@ -1099,6 +1121,27 @@
"rxjs": "^6.5.3 || ^7.4.0"
}
},
+ "node_modules/@angular/ssr": {
+ "version": "22.2.3",
+ "resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-22.2.3.tgz",
+ "integrity": "sha512-u/jeHCvSBMf9H72/rxrzON0UQLXux73pgft2Az4lgyhEgnFq+ttTUVoDSABcUoqZJKfh4pSTiCuFZJCpYB+xJQ==",
+ "license": "MIT",
+ "dependencies": {
+ "beasties": "0.5.4",
+ "tslib": "^2.3.0"
+ },
+ "peerDependencies": {
+ "@angular/common": "^22.0.0",
+ "@angular/core": "^22.0.0",
+ "@angular/platform-server": "^22.0.0",
+ "@angular/router": "^22.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@angular/platform-server": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@asamuzakjp/css-color": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.3.tgz",
@@ -4738,7 +4781,6 @@
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/beasties/-/beasties-0.5.4.tgz",
"integrity": "sha512-8/lOOjT+PElYCnaUbmhgd3Uad5R1VTQ4Wg0k4rK8bX04m5WEqHNmnlLy3Q/lJ7dk25XA+72gayzc/skTfKKpcA==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"css-select": "^7.0.0",
@@ -4769,7 +4811,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
"integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
- "dev": true,
"license": "ISC",
"engines": {
"node": ">=20.19.0"
@@ -5058,7 +5099,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
"integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^2.0.0",
@@ -5093,7 +5133,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
"integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
- "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
@@ -5178,7 +5217,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
@@ -5197,7 +5235,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -5213,7 +5250,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^3.0.0"
@@ -5230,7 +5266,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^3.0.0",
@@ -5871,7 +5906,6 @@
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
- "dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
@@ -6652,7 +6686,6 @@
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -6728,7 +6761,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
"integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
- "dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^2.0.0"
@@ -6960,7 +6992,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -6993,7 +7024,6 @@
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
- "dev": true,
"funding": [
{
"type": "opencollective",
@@ -7022,14 +7052,12 @@
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz",
"integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==",
- "dev": true,
"license": "MIT"
},
"node_modules/postcss-safe-parser": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.1.0.tgz",
"integrity": "sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==",
- "dev": true,
"funding": [
{
"type": "opencollective",
@@ -7714,7 +7742,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
- "dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -8368,6 +8395,15 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/xhr2": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.2.1.tgz",
+ "integrity": "sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
diff --git a/docs/package.json b/docs/package.json
index 52af055..7d7a714 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -4,8 +4,8 @@
"private": true,
"scripts": {
"ng": "ng",
- "start": "node scripts/gen-version.mjs && ng serve",
- "build": "node scripts/gen-version.mjs && ng build",
+ "start": "node scripts/gen-version.mjs && node scripts/gen-sitemap.mjs && ng serve",
+ "build": "node scripts/gen-version.mjs && node scripts/gen-sitemap.mjs && ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test && vitest run --config vitest.scripts.config.mjs",
"lint": "ng lint"
@@ -18,7 +18,9 @@
"@angular/forms": "^22.2.3",
"@angular/material": "^22.2.3",
"@angular/platform-browser": "^22.2.3",
+ "@angular/platform-server": "^22.2.3",
"@angular/router": "^22.2.3",
+ "@angular/ssr": "^22.2.3",
"@softwarity/projects": "^1.0.30",
"prismjs": "^1.30.0",
"rxjs": "~7.8.0",
diff --git a/docs/scripts/gen-sitemap.mjs b/docs/scripts/gen-sitemap.mjs
new file mode 100644
index 0000000..4af6b79
--- /dev/null
+++ b/docs/scripts/gen-sitemap.mjs
@@ -0,0 +1,35 @@
+// Writes src/root/sitemap.xml from the router's own list of pages, so a page
+// added to app.routes.ts is in the sitemap without anyone remembering it. Run
+// by the `build`/`start` npm scripts, before Angular builds; the file is served
+// at the site's root beside robots.txt (angular.json, assets).
+//
+// A page is a route with a component. Redirects (an old address kept working)
+// and the catch-all are not pages, and listing them would hand a crawler
+// addresses that only bounce. Each address ends with a slash: the pages are
+// pre-rendered into /index.html, and GitHub Pages answers /page with a
+// redirect to /page/.
+import { readFileSync, writeFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+// The same origin the pages name as canonical (src/app/page-meta.ts).
+export const ORIGIN = 'https://plug.softwarity.dev';
+
+export function pagesOf(routesSource) {
+ const found = [...routesSource.matchAll(/\bpath: '([^']*)'/g)];
+ return found
+ .filter((m, i) => routesSource.slice(m.index, found[i + 1]?.index).includes('loadComponent'))
+ .map((m) => m[1]);
+}
+
+export function sitemap(origin, pages) {
+ const urls = pages.map((p) => ` ${origin}/${p ? p + '/' : ''}`).join('\n');
+ return `\n\n${urls}\n\n`;
+}
+
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ const docs = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+ const pages = pagesOf(readFileSync(resolve(docs, 'src/app/app.routes.ts'), 'utf8'));
+ writeFileSync(resolve(docs, 'src/root/sitemap.xml'), sitemap(ORIGIN, pages));
+ console.log(`sitemap.xml: ${pages.length} pages`);
+}
diff --git a/docs/scripts/gen-sitemap.spec.mjs b/docs/scripts/gen-sitemap.spec.mjs
new file mode 100644
index 0000000..6be30f4
--- /dev/null
+++ b/docs/scripts/gen-sitemap.spec.mjs
@@ -0,0 +1,34 @@
+// The sitemap is read from the real routes file: what is proven is that the
+// pages a reader can open are the ones a crawler is given, and nothing else.
+import { readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+import { ORIGIN, pagesOf, sitemap } from './gen-sitemap.mjs';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const routes = readFileSync(resolve(here, '../src/app/app.routes.ts'), 'utf8');
+const meta = readFileSync(resolve(here, '../src/app/page-meta.ts'), 'utf8');
+
+describe('gen-sitemap', () => {
+ it('lists every page with a component, and no redirect or catch-all', () => {
+ const pages = pagesOf(routes);
+ expect(pages).toContain('');
+ expect(pages).toContain('kubernetes');
+ expect(pages).toContain('security');
+ expect(pages).not.toContain('agent');
+ expect(pages).not.toContain('**');
+ expect(pages.length).toBe((routes.match(/loadComponent/g) ?? []).length);
+ });
+
+ it('gives each page its address with a trailing slash, the home page bare', () => {
+ const xml = sitemap(ORIGIN, ['', 'cli']);
+ expect(xml).toContain('https://plug.softwarity.dev/');
+ expect(xml).toContain('https://plug.softwarity.dev/cli/');
+ expect(xml.startsWith(' {
+ expect(meta).toContain(`siteOrigin = '${ORIGIN}'`);
+ });
+});
diff --git a/docs/src/app/app.component.ts b/docs/src/app/app.component.ts
index b1ed10c..56bcead 100644
--- a/docs/src/app/app.component.ts
+++ b/docs/src/app/app.component.ts
@@ -1,7 +1,9 @@
-import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, inject, viewChild } from '@angular/core';
+import { Component, CUSTOM_ELEMENTS_SCHEMA, ElementRef, inject, PLATFORM_ID, viewChild } from '@angular/core';
+import { isPlatformBrowser } from '@angular/common';
import { NavigationEnd, Router, RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
import { filter } from 'rxjs/operators';
import { MatIconModule, MatIconRegistry } from '@angular/material/icon';
+import { PageMeta } from './page-meta';
interface DocLink {
path: string;
@@ -30,6 +32,10 @@ export class AppComponent {
const router = inject(Router);
// Use Material Symbols (loaded in index.html) as the default glyph set for every .
iconRegistry.setDefaultFontSetClass('material-symbols-outlined');
+ inject(PageMeta).start();
+ // Focus and scrolling belong to a reader's browser, not to the build that
+ // renders the pages ahead of time.
+ if (!isPlatformBrowser(inject(PLATFORM_ID))) return;
// Move focus into the page after every navigation, and scroll it to the top.
//
@@ -51,14 +57,15 @@ export class AppComponent {
{ path: '/', label: 'About', icon: 'sync_alt' },
{ path: '/comparison', label: 'Compared to others', icon: 'compare_arrows' },
{ path: '/getting-started', label: 'Getting started', icon: 'rocket_launch' },
+ { path: '/install', label: 'Install', icon: 'download' },
{ path: '/use-cases', label: 'Use cases', icon: 'lightbulb' },
{ path: '/cli', label: 'CLI reference', icon: 'terminal' },
{ path: '/build-tools', label: 'From your build tool', icon: 'integration_instructions' },
{ path: '/how-it-works', label: 'How it works', icon: 'account_tree' },
{ path: '/under-the-hood', label: 'Under the hood', icon: 'build' },
{ path: '/profiles', label: 'Profiles & versions', icon: 'settings' },
- { path: '/swarm', label: 'Swarm', icon: 'dns' },
- { path: '/kubernetes', label: 'Kubernetes', icon: 'hub' },
+ { path: '/swarm', label: 'Docker, Compose & Swarm', icon: 'dns' },
+ { path: '/kubernetes', label: 'Kubernetes & OpenShift', icon: 'hub' },
{ path: '/mcp', label: 'MCP for AI agents', icon: 'smart_toy' },
{ path: '/security', label: 'Security model', icon: 'shield' },
{ path: '/meerkat', label: 'Meerkat', icon: 'shield_person', logo: 'meerkat' },
diff --git a/docs/src/app/app.config.server.ts b/docs/src/app/app.config.server.ts
new file mode 100644
index 0000000..8aaf70c
--- /dev/null
+++ b/docs/src/app/app.config.server.ts
@@ -0,0 +1,8 @@
+import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
+import { provideServerRendering, withRoutes } from '@angular/ssr';
+import { appConfig } from './app.config';
+import { serverRoutes } from './app.routes.server';
+
+export const config: ApplicationConfig = mergeApplicationConfig(appConfig, {
+ providers: [provideServerRendering(withRoutes(serverRoutes))],
+});
diff --git a/docs/src/app/app.config.ts b/docs/src/app/app.config.ts
index f0ba279..e8e19d2 100644
--- a/docs/src/app/app.config.ts
+++ b/docs/src/app/app.config.ts
@@ -1,10 +1,17 @@
-import { ApplicationConfig } from '@angular/core';
-import { provideRouter, withHashLocation } from '@angular/router';
+import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
+import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { routes } from './app.routes';
// The release tag is not fetched at runtime: scripts/gen-version.mjs pins it
// into the manifests copied to src/assets at build time, and serves
// those. Anything that needs the tag reads it from there.
+//
+// Routed on the path (/kubernetes), so each page has an address of its own that
+// is rendered at build time; addresses from the hash era (/#/kubernetes) are
+// moved onto it before the app starts (main.ts).
export const appConfig: ApplicationConfig = {
- providers: [provideRouter(routes, withHashLocation())],
+ providers: [
+ provideZonelessChangeDetection(),
+ provideRouter(routes, withInMemoryScrolling({ anchorScrolling: 'enabled' })),
+ ],
};
diff --git a/docs/src/app/app.routes.server.ts b/docs/src/app/app.routes.server.ts
new file mode 100644
index 0000000..cb23359
--- /dev/null
+++ b/docs/src/app/app.routes.server.ts
@@ -0,0 +1,5 @@
+import { RenderMode, ServerRoute } from '@angular/ssr';
+
+// Every page is static: rendered once at build time into its own index.html,
+// which GitHub Pages serves as it is. No server runs anywhere.
+export const serverRoutes: ServerRoute[] = [{ path: '**', renderMode: RenderMode.Prerender }];
diff --git a/docs/src/app/app.routes.ts b/docs/src/app/app.routes.ts
index f90bda7..2283d6b 100644
--- a/docs/src/app/app.routes.ts
+++ b/docs/src/app/app.routes.ts
@@ -2,80 +2,102 @@ import { Routes } from '@angular/router';
// A title per route: on a single-page site the browser tab, the history entry
// and what a screen reader announces on navigation all come from
-// document.title. Angular's default TitleStrategy applies these.
+// document.title. Angular's default TitleStrategy applies these. The
+// description is the page's meta description, written into its pre-rendered
+// HTML with its canonical address (page-meta.ts).
export const routes: Routes = [
{
path: '',
- title: 'plug - About',
+ title: 'plug - A local process as a member of your cluster',
+ data: { description: "plug runs a local process as a member of your cluster: it resolves the cluster's service names, reaches its services, and is reachable under a name of its own, with no change to the application. Docker, Compose, Swarm, Kubernetes, OpenShift." },
pathMatch: 'full',
loadComponent: () => import('./pages/about.component').then((m) => m.AboutComponent),
},
{
path: 'getting-started',
title: 'plug - Getting started',
+ data: { description: 'Deploy the plug agent on a cluster, install the CLI from it in one command, and run your first local process as a member of the cluster.' },
loadComponent: () => import('./pages/getting-started.component').then((m) => m.GettingStartedComponent),
},
+ {
+ path: 'install',
+ title: 'plug - Install',
+ data: { description: 'Install the plug agent on Docker Compose, Docker Swarm, Kubernetes or OpenShift, and the CLI on Linux, macOS or Windows from that agent.' },
+ loadComponent: () => import('./pages/install.component').then((m) => m.InstallComponent),
+ },
{
path: 'use-cases',
title: 'plug - Use cases',
+ data: { description: 'Scenarios where plug fits: debugging against a real cluster, a front end on the laptop, QA trying a branch before its pull request, two developers or AI agents on one feature.' },
loadComponent: () => import('./pages/use-cases.component').then((m) => m.UseCasesComponent),
},
{
path: 'cli',
title: 'plug - CLI reference',
+ data: { description: 'Every plug command and option: serving a name with -s, pure client with -c, profiles, environment and volume projection, doctor, update.' },
loadComponent: () => import('./pages/cli.component').then((m) => m.CliComponent),
},
{
path: 'build-tools',
title: 'plug - From your build tool',
+ data: { description: 'Running plug from npm, Maven, Gradle, Composer, Deno, PDM, Hatch, Make or just, and from the IDE with a debugger attached.' },
loadComponent: () => import('./pages/build-tools.component').then((m) => m.BuildToolsComponent),
},
{
path: 'comparison',
title: 'plug - How it compares',
+ data: { description: "How plug compares with mirrord and Telepresence, row by row, from each tool's public documentation." },
loadComponent: () => import('./pages/comparison.component').then((m) => m.ComparisonComponent),
},
{
path: 'how-it-works',
title: 'plug - How it works',
+ data: { description: 'How plug works: one SSH connection carries both directions, a deployed service is parked for the session, its volumes are mounted live, and the session cleans up after itself.' },
loadComponent: () => import('./pages/how-it-works.component').then((m) => m.HowItWorksComponent),
},
{
path: 'under-the-hood',
title: 'plug - Under the hood',
+ data: { description: "plug's data path step by step: a userspace TUN on your machine, SSH to an agent in the cluster, the privilege granted once, several clusters at once." },
loadComponent: () => import('./pages/under-the-hood.component').then((m) => m.UnderTheHoodComponent),
},
{
path: 'profiles',
title: 'plug - Profiles & versions',
+ data: { description: "plug profiles: one per cluster, automatic selection, split-horizon DNS, and the launcher that runs the version each cluster's agent serves." },
loadComponent: () => import('./pages/profiles.component').then((m) => m.ProfilesComponent),
},
{
path: 'swarm',
- title: 'plug - Swarm',
+ title: 'plug - Docker, Compose and Swarm',
+ data: { description: 'Deploy the plug agent on Docker, Compose or Swarm: in your stack or standalone, the Docker socket it needs, image tags, and how it publishes a name in the cluster.' },
loadComponent: () => import('./pages/agent-swarm.component').then((m) => m.AgentSwarmComponent),
},
{
path: 'kubernetes',
- title: 'plug - Kubernetes',
+ title: 'plug - Kubernetes and OpenShift',
+ data: { description: 'Deploy the plug agent on Kubernetes, OpenShift or OKD: the manifest, the namespace-scoped Role rule by rule, and how to reach the agent.' },
loadComponent: () =>
import('./pages/agent-kubernetes.component').then((m) => m.AgentKubernetesComponent),
},
{
path: 'mcp',
title: 'plug - MCP for AI coding agents',
+ data: { description: "plug mcp: an MCP server that gives an AI coding agent the clusters a machine knows, doctor checks, a workload's environment and DNS lookups." },
loadComponent: () => import('./pages/mcp.component').then((m) => m.McpComponent),
},
{
path: 'troubleshooting',
title: 'plug - Troubleshooting',
+ data: { description: 'Fixes for the problems plug users meet: an agent that will not start, a name nothing reaches, a service that did not come back, unknown hosts.' },
loadComponent: () =>
import('./pages/troubleshooting.component').then((m) => m.TroubleshootingComponent),
},
{
path: 'continuous-deployment',
title: 'plug - CD & GitOps',
+ data: { description: 'plug alongside Argo CD, Flux, Rancher Fleet and operators: why a GitOps controller reverts a takeover, and the setting that lets both coexist.' },
loadComponent: () =>
import('./pages/continuous-deployment.component').then((m) => m.ContinuousDeploymentComponent),
},
@@ -83,26 +105,31 @@ export const routes: Routes = [
{
path: 'security',
title: 'plug - Security model',
+ data: { description: "plug's security model: who may open a session (open, by approval, hosted), what a session can reach once in, the Docker socket, signed binaries." },
loadComponent: () => import('./pages/security.component').then((m) => m.SecurityComponent),
},
{
path: 'meerkat',
title: 'plug - Meerkat',
+ data: { description: 'Meerkat, the app gateway that embeds the plug agent: sessions tied to named developers, visible to everyone using the cluster.' },
loadComponent: () => import('./pages/meerkat.component').then((m) => m.MeerkatComponent),
},
{
path: 'integration',
title: 'plug - Integration',
+ data: { description: 'How a gateway embeds the plug agent as a Go package: the Host it implements, Start, the hosted client, cluster rights, following and ending sessions.' },
loadComponent: () => import('./pages/integration.component').then((m) => m.IntegrationComponent),
},
{
path: 'roadmap',
title: 'plug - Roadmap',
+ data: { description: 'What is planned for plug, and what is left out on purpose.' },
loadComponent: () => import('./pages/roadmap.component').then((m) => m.RoadmapComponent),
},
{
path: 'coverage',
title: 'plug - Coverage matrix',
+ data: { description: "plug's coverage matrix: what is proven in CI on each cluster backend and each client OS, and what is not proven yet." },
loadComponent: () => import('./pages/coverage.component').then((m) => m.CoverageComponent),
},
{ path: '**', redirectTo: '' },
diff --git a/docs/src/app/file/file.component.ts b/docs/src/app/file/file.component.ts
index d3f647a..bf591d2 100644
--- a/docs/src/app/file/file.component.ts
+++ b/docs/src/app/file/file.component.ts
@@ -3,12 +3,15 @@ import {
Component,
ElementRef,
OnInit,
+ PLATFORM_ID,
computed,
effect,
+ inject,
input,
signal,
viewChild,
} from '@angular/core';
+import { isPlatformBrowser } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { Prism } from '../prism';
@@ -340,7 +343,12 @@ export class FileComponent implements OnInit {
pre.style.overflowY = 'auto';
}
+ private readonly browser = isPlatformBrowser(inject(PLATFORM_ID));
+
ngOnInit(): void {
+ // Fetched by the reader's browser: the build that renders the pages ahead
+ // of time has no server to fetch it from.
+ if (!this.browser) return;
fetch(new URL(this.src(), document.baseURI))
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((t) => {
diff --git a/docs/src/app/page-meta.ts b/docs/src/app/page-meta.ts
new file mode 100644
index 0000000..d85f6f8
--- /dev/null
+++ b/docs/src/app/page-meta.ts
@@ -0,0 +1,56 @@
+import { DOCUMENT, inject, Injectable } from '@angular/core';
+import { Meta } from '@angular/platform-browser';
+import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router';
+import { filter } from 'rxjs/operators';
+
+// The address the site is served from. Canonical links and link previews name
+// it, whatever host the page was opened on (a mirror, a preview, localhost).
+export const siteOrigin = 'https://plug.softwarity.dev';
+
+/**
+ * Writes each page's own description and canonical address into the document,
+ * on every navigation. It runs when the pages are rendered at build time too,
+ * so the HTML a crawler or a link preview reads carries them, not only the
+ * page a browser has finished drawing.
+ */
+@Injectable({ providedIn: 'root' })
+export class PageMeta {
+ private readonly meta = inject(Meta);
+ private readonly doc = inject(DOCUMENT);
+ private readonly router = inject(Router);
+
+ start(): void {
+ this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe(() => {
+ let route: ActivatedRouteSnapshot = this.router.routerState.snapshot.root;
+ while (route.firstChild) route = route.firstChild;
+ const description = route.data['description'] as string | undefined;
+ const path = this.router.url.split(/[?#]/)[0];
+ // With its trailing slash: each page is /index.html, and GitHub
+ // Pages answers /page by redirecting to /page/.
+ const url = siteOrigin + (path === '/' ? '/' : path + '/');
+ if (description) {
+ this.meta.updateTag({ name: 'description', content: description });
+ this.meta.updateTag({ property: 'og:description', content: description });
+ this.meta.updateTag({ name: 'twitter:description', content: description });
+ }
+ const title = this.doc.title;
+ if (title) {
+ this.meta.updateTag({ property: 'og:title', content: title });
+ this.meta.updateTag({ name: 'twitter:title', content: title });
+ }
+ this.meta.updateTag({ property: 'og:url', content: url });
+ this.meta.updateTag({ name: 'twitter:url', content: url });
+ this.canonical(url);
+ });
+ }
+
+ private canonical(url: string): void {
+ let link = this.doc.head.querySelector('link[rel="canonical"]');
+ if (!link) {
+ link = this.doc.createElement('link');
+ link.setAttribute('rel', 'canonical');
+ this.doc.head.appendChild(link);
+ }
+ link.setAttribute('href', url);
+ }
+}
diff --git a/docs/src/app/pages/about.component.ts b/docs/src/app/pages/about.component.ts
index 6ec1a87..f813b3a 100644
--- a/docs/src/app/pages/about.component.ts
+++ b/docs/src/app/pages/about.component.ts
@@ -155,9 +155,17 @@ import { MatIconModule } from '@angular/material/icon';
- Two pieces. A tiny agent container (one static Go
+ Two pieces. A tiny agent container (one static Go
binary, no shell) deployed once on the cluster - and a single static plug binary on each dev
- machine. Set up once per cluster; after that, day-to-day runs need no sudo or admin.
+ machine. Set up once per cluster; after that, day-to-day runs need no sudo or admin. The
+ agent runs on Docker Compose, Docker Swarm, Kubernetes and OpenShift
+ (what is proven where).
+
+
+ Before you deploy it. By default the agent lets in whoever reaches its port,
+ and a session reaches what the agent reaches. On Docker, Compose and Swarm it needs the
+ Docker socket, which is root on the host. Use it on dev or staging clusters you trust; the
+ security model says how to narrow who gets in.
What you get
@@ -196,12 +204,10 @@ import { MatIconModule } from '@angular/material/icon';
How plug compares
- plug isn't the only way to run local code against a remote cluster:
mirrord and
- Telepresence are the
- well-known Kubernetes-native tools. plug goes further, with the developer's experience
- first: it works the same on Docker, Compose, Swarm, Kubernetes and OpenShift, from Linux,
- macOS and Windows, and integrates more closely on many points.
+ Telepresence also run
+ local code against a remote cluster, on Kubernetes. plug works the same on Docker Compose,
+ Swarm, Kubernetes and OpenShift, and needs no kubeconfig on a developer's machine.
Side by side, including where they are ahead.
The same agent image runs on Kubernetes - a small pod
@@ -55,7 +55,7 @@ import { FileComponent } from '../file/file.component';
-
OpenShift and OKD
+
OpenShift and OKD
The same manifest and the same Role, admitted by the default restricted-v2 SCC
with nothing to grant: the agent runs as the uid the cluster picks from the namespace's
diff --git a/docs/src/app/pages/agent-swarm.component.ts b/docs/src/app/pages/agent-swarm.component.ts
index a1792a4..dd9e614 100644
--- a/docs/src/app/pages/agent-swarm.component.ts
+++ b/docs/src/app/pages/agent-swarm.component.ts
@@ -7,7 +7,7 @@ import { FileComponent } from '../file/file.component';
selector: 'app-agent-swarm',
imports: [CodeComponent, RouterLink, FileComponent],
template: `
-
Swarm
+
Docker, Compose and Swarm
The agent is deliberately boring: a small image holding one static Go binary and no shell at
diff --git a/docs/src/app/pages/comparison.component.ts b/docs/src/app/pages/comparison.component.ts
index db041d5..1233ba2 100644
--- a/docs/src/app/pages/comparison.component.ts
+++ b/docs/src/app/pages/comparison.component.ts
@@ -102,91 +102,70 @@ import { CtaComponent } from '../cta/cta.component';
],
template: `
How plug compares
-
mirrord and
- Telepresence are the
- well-known Kubernetes-native tools for running local code against a remote
- cluster. plug goes further, with the developer's experience first: the same behaviour on
- Docker, Compose, Swarm, Kubernetes and OpenShift, nothing to install or keep up to
- date on a
- developer's machine, and a closer integration on many points, from the workload's
- environment and secret files to its volumes, live.
+ Telepresence are two
+ established tools for running local code against a remote Kubernetes
+ cluster. The table below sets them beside plug, row by row. What it says about them comes from
+ their public documentation, read in October 2026; both move quickly, so check a row that
+ matters to you against their current docs.
- One difference shapes the others: plug needs no existing service to mirror or
- intercept. plug -s my-api:8080:3000 npm run start:dev puts the name
- my-api in the cluster for the session, and the cluster reaches your process by
- it, whether a my-api was deployed or not. When one is, plug takes its place and
- gives it back on exit.
+ One difference shapes several rows. To receive traffic from the cluster, mirrord and
+ Telepresence attach to a workload that is already deployed. plug publishes a name instead:
+ plug -s my-api:8080:3000 npm run start:dev puts my-api in the
+ cluster for the session, whether a my-api was deployed or not. When one is, plug
+ sets it aside and restores it on exit.
Kubernetes (OpenShift after SCC changes, per its FAQ)
Kubernetes · OpenShift
Your machine
Linux - amd64, arm64 macOS - amd64, arm64 Windows - amd64, arm64
Linux - amd64, arm64 macOS - amd64, arm64 Windows - amd64
Linux - amd64, arm64 macOS - amd64, arm64 Windows - amd64, arm64
-
What a developer needs
the cluster's address - plug installs itself from it
a kubeconfig with rights on the cluster
a kubeconfig with rights on the cluster
-
What you point it at
a name - which need not exist in the cluster yet, and can still take over one that does
an existing workload, named with --target
an existing service, to intercept
-
Setup, your machine
one ssh command, served by the cluster - always the version the agent runs
brew / curl / choco
package or installer
-
Staying up to date
the launcher follows the version its cluster's agent serves, per cluster: a session on an updated cluster downloads that core once and runs it; plug update moves the agent, update=notify|auto says or does it for you
reinstall the CLI (brew / curl); the operator is upgraded separately
reinstall the CLI (brew / package); the traffic-manager is upgraded separately, by Helm
-
Several clusters on several versions
one launcher, one cached core per version - each profile runs what its agent serves, nothing to pick
one CLI version at a time
one CLI version at a time
-
Setup, cluster side
one agent container
none
traffic-manager
+
What a developer needs
the cluster's address: plug installs itself from it
a kubeconfig with rights to start its agent pod, or the Operator (paid plans)
a kubeconfig; little or no RBAC once an administrator installed the traffic-manager
+
To receive cluster traffic
a name, which need not exist in the cluster, and can take over one that does
an existing workload, named with --target (targetless mode: outgoing traffic only)
an existing workload, attached with replace, intercept or wiretap
+
Setup, your machine
one ssh command, served by the cluster, at the version its agent runs
brew, curl, choco or an MSI
a package, an installer or brew
+
Staying up to date
the launcher runs the version each cluster's agent serves, one cached core per version; plug update moves the agent
reinstall the CLI; the Operator, where used, is upgraded separately
reinstall the CLI; the traffic-manager is upgraded separately, with Helm
+
Setup, cluster side
one agent container
none: an agent pod per session (the Operator on paid plans)
the traffic-manager, installed once
Reach cluster services by name
✓
✓
✓
-
Be reachable by a cluster name
✓ -s name:8080:3000 - the name is deployed for the session, no existing service needed
by stealing or mirroring an existing pod's traffic
by intercepting an existing service
-
Any runtime, no code change
✓ (IP layer)
✓
✓
+
Be reachable by a cluster name
✓ -s name:8080:3000: the name is created for the session, no existing service needed
by stealing or mirroring an existing workload's traffic
by replacing or intercepting an existing workload
+
Runtimes
any, unchanged (IP layer)
programs linked against libc, and Go; not statically linked binaries
any, unchanged (network level)
Run a container as a member
✓ --dockerrun
✓ mirrord container
✓ --docker-run
-
Inherit the workload's environment
✓ by default on a takeover, secrets as the pod has them; the cluster's value wins over an inherited one (behaves as in the cluster), and --no-env keeps yours instead - whole OR key by key (--no-env DB_URL,API_KEY)
✓ by default, from the target pod
✓ on intercept, or --env-file
-
Borrow ANOTHER workload's environment
✓ --env-of <name> - a pure client (-c) run with a service's own credentials, or a -s served with the variables of the service it talks to, without parking it
only the --target you intercept
only the service you intercept
-
Its mounted secret/config FILES (a CA, a keystore)
✓ no FUSE: the files are read through the agent's existing exec/API and materialised for the session (or mounted at their exact path under --dockerrun); the variables that name them are repointed. Kubernetes and Compose secrets, Swarm configs
fs mirroring, live - needs its syscall layer in-process
volume mounts, live - via sshfs / FUSE (macFUSE, WinFSP)
-
Its data VOLUMES / PVCs, live
✓ by default, nothing to install: every volume of the workload a -s takes over (or --env-of names) is mounted on your machine read-write, and the variables naming it are repointed - the process finds its data and knows nothing (--no-mount to opt out, --mount for an exact path). Mounted with the SMB client the OS already has, on all three OSes; the agent runs a helper beside the workload, tied to the session like the name
✓ file calls hooked in your process and relayed into the pod one by one (the runtime has to be hookable)
✓ --mount, through sshfs - a FUSE runtime and sshfs to install first
-
Several developers on one cluster
✓ each with their own sessions, at the same time. A name already served is refused, and the refusal says where it is held from
✓ with the paid Team plan; the free version is for one developer
✓
-
Two developers on the SAME service at once
one holder per name: the next one is refused and told by whom
split per request on a header (HTTP) or a message attribute (queues): the header has to be set at the entry point and passed on by every service in between; paid Team plan
split per request on a header or a path (HTTP): the header has to be set at the entry point and passed on by every service in between
-
Who gets in
✓ by approval: deploy the agent with PLUG_ADMISSION=invite and only the keys a member accepted get in, each one revocable (plug revoke), with no account to create in the cluster (how). Open by default, for a trusted dev network
whoever holds a kubeconfig the cluster issued
whoever holds a kubeconfig the cluster issued
-
What each person may do
every member, everything plug does; per-person identities and rights with Meerkat
kubeconfig RBAC
kubeconfig RBAC
-
IDE extensions
none, CLI only - there is no target to pick, so nothing for a panel to show
VS Code · JetBrains
JetBrains
-
For an AI coding agent
✓ plug mcp, an MCP server over stdio: the clusters this machine knows, doctor check by check, a workload's environment, whether a name exists, as tools with structured answers
the agent drives the CLI like a developer would; no MCP documented
✓ read through the agent and written for the session, the variables that name them repointed; Kubernetes and Compose secrets, Swarm configs
✓ file calls relayed into the pod by its in-process layer; remote reads by default, read-write configured per path
✓ volume mounts through sshfs: FUSE-T on macOS, WinFsp and SSHFS-Win on Windows; none in Docker mode
+
Its data volumes, live
✓ by default on a takeover, read-write, with the SMB client the OS already has; nothing to install
✓ through its in-process file layer
✓ mounted by default, through sshfs (FUSE-T or WinFsp to install)
+
Several developers on one cluster
✓ each with their own sessions. A name already served is refused, and the refusal says where it is held from
✓ on the paid Team plan; the free version is for one developer
✓
+
Two developers on the same service at once
one holder per name: the next one is refused
split per request on HTTP headers, path or method, or a queue message attribute; concurrent use on the paid Team plan
split per request on a header or a path (HTTP)
+
Who gets in
open by default; by approval with PLUG_ADMISSION=invite, each key accepted by a member and revocable, no account in the cluster (how)
whoever holds a kubeconfig the cluster issued
whoever the cluster lets reach its traffic-manager
+
What each person may do
every member, everything plug does; per-person rights through a gateway such as Meerkat
Kubernetes RBAC; Operator policies on paid plans
Kubernetes RBAC
+
IDE extensions
none, CLI only
VS Code · JetBrains
JetBrains (a JetBrains plugin)
+
For an AI coding agent
plug mcp: the clusters a machine knows, doctor, a workload's environment, DNS lookups
mirrord mcp, plus agent skills
telepresence mcp
+
Mechanism
userspace TUN over SSH, an agent in the cluster
libc and syscall hooks loaded into your process, an agent pod in the cluster
a virtual network interface on your machine, a traffic-manager and traffic-agents in the cluster
+
Behind it
Softwarity
MetalBear
a CNCF Sandbox project, created by Ambassador Labs
+
Price
free, every feature
free for one developer; the Team plan, per seat, for a shared cluster
free
License
FSL-1.1-Apache-2.0
MIT
Apache-2.0
-
-
Green marks whichever tool is ahead on the fact stated in that row - including when it is not plug, and both of them when two are ahead of the third. Rows where all three are level are left plain.
-
+
Green marks whichever tool is ahead on the fact stated in that row, whichever it is, and every one of them when several are. Rows where all three are level are left plain.
- Why plug: it behaves the same whichever backend provisions the name, and a
- developer needs nothing but the cluster's address - no Kubernetes tooling, no account in the
- cluster. The setup lives once in the cluster instead of on every desk, which is also why it
- works where a kubeconfig does not exist at all: Docker, Compose, Swarm. And it can still
- choose who comes in: by approval, a newcomer asks once and a member lets them in.
+ Where plug differs: a developer needs the cluster's address and nothing else:
+ no kubeconfig, no Kubernetes tooling, no account in the cluster. That is also why it works
+ where there is no kubeconfig at all: Docker Compose and Swarm. And the name it serves does not
+ have to exist yet: the service you are writing, deployed nowhere, is reachable from the rest
+ of the stack by its future name.
-
- And there is nothing to point at. Both of the others work by substitution:
- you name an existing workload and they stand in its place, which is why they need a target and
- a dialog to pick it. plug ADDS a member to the cluster, so you declare a name and that is the
- whole of it - a name that does not have to exist yet. The service you are writing
- right now, which is deployed nowhere, is reachable from the rest of the stack by its future
- name. And when a deployed workload does own that name, plug parks it for the session and puts
- it back on the way out.
+ Where they are ahead: both rest on Kubernetes RBAC, which says per person
+ what each one may do; plug decides who gets in, not what each member may do once in (see the
+ security model). Both split one shared service between several
+ developers request by request, provided the header is set where the request enters and every
+ service in between passes it on. And both have IDE extensions.
-
-
- Where they are ahead: both rest on your kubeconfig and its RBAC, which says
- per person what each one may do. plug decides who gets in - by approval, or whoever reaches
- the agent when it is left open - but not what each member may do once in (see the
- security model, and Meerkat for
- named identities and their rights). Both split one shared service between several developers, request by request, on a header
- or a path (with mirrord, on its paid Team plan), provided the header is set where the
- request enters and every service in between passes it on. And both are older, with IDE extensions and a larger community.
-
-
Set it up
`,
})
diff --git a/docs/src/app/pages/getting-started.component.ts b/docs/src/app/pages/getting-started.component.ts
index d5f837d..4e87a0a 100644
--- a/docs/src/app/pages/getting-started.component.ts
+++ b/docs/src/app/pages/getting-started.component.ts
@@ -68,8 +68,8 @@ function guessOs(): Os {
refuses to start rather than fail on your first -s. It is root on
the host, so mount it only on a cluster you trust. Kubernetes needs no socket - a Services-only
RBAC role instead, and the same rule applies. See
- Swarm for the standalone variant, or
- Kubernetes for the cluster.
+ Install for every platform: Compose, Swarm, Kubernetes,
+ OpenShift, or one agent for several stacks.
Left as is, the agent lets in whoever reaches its port: the right default on a network you
diff --git a/docs/src/app/pages/how-it-works.component.ts b/docs/src/app/pages/how-it-works.component.ts
index 87a340b..3c6e39e 100644
--- a/docs/src/app/pages/how-it-works.component.ts
+++ b/docs/src/app/pages/how-it-works.component.ts
@@ -1,9 +1,10 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
+import { CodeComponent } from '../code/code.component';
@Component({
selector: 'app-how-it-works',
- imports: [RouterLink],
+ imports: [CodeComponent, RouterLink],
styles: [
`
.diagram {
@@ -131,6 +132,26 @@ import { RouterLink } from '@angular/router';
restarting your command.
+
Names that are not the cluster's
+
+ A name the cluster does not know is forwarded to the nameservers your machine already used,
+ read from the system (scutil on macOS, the former resolv.conf on
+ Linux, the adapter table on Windows) and ranked the way the OS ranks its interfaces, so a
+ corporate VPN's resolver comes first. plug follows them through the session: a VPN connected
+ or dropped, another network joined. plug doctor shows where lookups go now:
+
+ ✓ dns forwarding forwarding dotted names to 10.8.0.1:53, 192.168.1.1:53
+
+ With none found, plug falls back to a public resolver and says so: public names keep working,
+ internal ones do not resolve, and those lookups leave your network.
+
+
+
What it does not carry
+
+ TCP to a name. UDP, QUIC and ping are not carried (most clients fall back to TCP), and neither
+ is a hard-coded IPv6 literal; a service reached by its name is always fine.
+
+
Several clusters at once
Two terminals can be plugged into two different clusters at the same time,
diff --git a/docs/src/app/pages/install.component.ts b/docs/src/app/pages/install.component.ts
new file mode 100644
index 0000000..9f8d899
--- /dev/null
+++ b/docs/src/app/pages/install.component.ts
@@ -0,0 +1,143 @@
+import { Component } from '@angular/core';
+import { RouterLink } from '@angular/router';
+import { CodeComponent } from '../code/code.component';
+import { FileComponent } from '../file/file.component';
+
+@Component({
+ selector: 'app-install',
+ imports: [CodeComponent, FileComponent, RouterLink],
+ styles: [
+ `
+ .toc {
+ columns: 2 220px;
+ margin: 8px 0 20px;
+ }
+ h3 {
+ margin-top: 36px;
+ }
+ `,
+ ],
+ template: `
+
Install
+
+ Two pieces: one agent per cluster, and the plug CLI on each
+ developer's machine, installed from that agent. The agent is the same image everywhere; only
+ the way it is deployed, and the right it needs to publish a name, change with the platform.
+
+ Whatever the platform, the agent lets in whoever reaches its port until you set
+ PLUG_ADMISSION: invite, and a session reaches what the agent reaches. Read the
+ security model before deploying it on a shared network.
+
+
+
Docker Compose
+
Add the agent to the stack you want to reach. It joins the stack's network by itself:
+
+
+
+ The Docker socket is required: the agent creates your -s name
+ as a small signpost container. It is root on the host; mount it only on a host you trust.
+ Without it the agent does not start.
+
+
+ With PLUG_ADMISSION: invite, mount the plug-members volume the
+ comment shows, or the agent does not start.
+
+
+ The port (2222 here) is a convention: publish any, and give it to the
+ profile.
+
+
+
+
Docker Swarm
+
+ The same service, deployed with the stack (docker stack deploy). Two
+ differences:
+
+
+
+ The signpost is a Swarm service, so the agent runs on a
+ manager, as one replica (the deploy: block above). The
+ signpost joins the stack's overlay whether or not it is attachable.
+
+
+ With PLUG_ADMISSION: invite, the agent keeps its members in a Swarm config by
+ itself: nothing to mount.
+
+
+
+ Through the routing mesh the agent answers on any node's address. Details:
+ Docker, Compose and Swarm.
+
+
+
Kubernetes
+
Apply the manifest in the namespace where the services you work on live:
+ No socket: a namespace-scoped Role lets the agent create a Service for your
+ name, repoint one it takes over, and read a workload's environment and volumes. Each rule
+ and its reason: Kubernetes and OpenShift.
+
+
+ Reach it on the NodePort the manifest publishes (32222), or with no exposed
+ port, through the API server and each developer's own kubeconfig:
+ kubectl -n my-namespace port-forward svc/plug 2222:2222
+
+
+ Re-apply the manifest when you upgrade the agent: plug update
+ moves the image, not the Role, and plug doctor names any rule a newer agent
+ is missing.
+
+
+
+
OpenShift and OKD
+
+ The same manifest and the same Role, admitted by the default restricted-v2 SCC
+ with nothing to grant: the agent runs as the uid the cluster picks, never root, and asks for
+ no capability. CI runs it on MicroShift built from OKD, installs plug from it and opens
+ sessions through it. Details: OpenShift and OKD.
+
+
+
One agent for several stacks
+
+ On Docker or Swarm, a single agent can cover several stacks: deploy
+ deploy/plug-stack.yml
+ and list their networks. The networks it is attached to are exactly what sessions reach.
+
+ The install does not check the agent's host key; plug pins it on the first connection after.
+ The binaries are signed, and plug verifies the signature before granting itself privilege
+ (security model). Then run your first command:
+ Getting started.
+
+ `,
+})
+export class InstallComponent {}
diff --git a/docs/src/app/pages/profiles.component.ts b/docs/src/app/pages/profiles.component.ts
index 25c3508..34d4d81 100644
--- a/docs/src/app/pages/profiles.component.ts
+++ b/docs/src/app/pages/profiles.component.ts
@@ -136,6 +136,30 @@ plug versions # launcher + every cached cluster version
already on the newest release, plug says so immediately instead of rolling the workload to
find out.
+
+
Being told a version is there
+
+ Once a day, in the background, a session checks whether the registry carries a release the
+ cluster's agent does not, and says so the next time you run plug. What happens then is the
+ profile's policy:
+
+ plug config -p neo # show that cluster's policy
+plug config -p neo update=notify # default: tell you, and offer to apply
+plug config -p local update=auto # apply it, on a cluster you govern
+plug config -p shared update=none # never look, on one you do not
+
+ The policy belongs to the profile, not to the machine, because applying an
+ update rolls the cluster's agent, which every session on that cluster shares: you may govern
+ your own cluster and have no say over a shared one. notify asks only on a real
+ terminal, defaults to no and gives up after a few seconds; a script or a CI job sees the
+ message and never a question. A running command keeps the version it started with; the new
+ one is used at the next launch.
+
+
+ A deployment on a moving tag (latest, a branch) is compared by digest, since its
+ name never changes. When the registry is out of reach from your machine (a proxy, a VPN that
+ splits routes), plug asks the cluster, which reaches it.
+
`,
})
export class ProfilesComponent {}
diff --git a/docs/src/app/pages/security.component.ts b/docs/src/app/pages/security.component.ts
index 038da7d..07a5127 100644
--- a/docs/src/app/pages/security.component.ts
+++ b/docs/src/app/pages/security.component.ts
@@ -36,6 +36,29 @@ import { CodeComponent } from '../code/code.component';
],
template: `
Security model
+
+ At a glance.
+
+
+ By default the agent authenticates no person: whoever reaches its port gets in. Admission
+ by approval, or a gateway, changes that (below).
+
+
+ A session reaches what the agent reaches: the networks it is attached to, and for any
+ workload there, its environment, secret files, volumes and name.
+
+
+ On Docker, Compose and Swarm the agent needs the Docker socket, which is root on the host.
+ Kubernetes and OpenShift use a namespace-scoped Role.
+
+
+ The CLI is installed over SSH from the agent, without checking its host key; plug pins
+ the key afterwards and verifies the signature of every binary before granting privilege.
+
+
+ Use it on dev or staging clusters you trust. Never expose the agent's port on a public
+ network, and keep it away from production workloads.
+
Who may open a session on a cluster is decided in one of three ways. Pick the one that fits
who can reach the agent.
diff --git a/docs/src/index.html b/docs/src/index.html
index c62aba6..1ebcf8f 100644
--- a/docs/src/index.html
+++ b/docs/src/index.html
@@ -2,10 +2,14 @@
- plug - your local process, inside the cluster · Documentation
+ plug - A local process as a member of your cluster
-
+
+
+
-
+
diff --git a/docs/src/main.server.ts b/docs/src/main.server.ts
new file mode 100644
index 0000000..34ba35c
--- /dev/null
+++ b/docs/src/main.server.ts
@@ -0,0 +1,10 @@
+import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
+import { AppComponent } from './app/app.component';
+import { config } from './app/app.config.server';
+
+// The build's own entry, never shipped: it renders every route once, at build
+// time, so each page is served as HTML a reader, a crawler or a link preview
+// can read without running the site's scripts (app.routes.server.ts).
+const bootstrap = (context: BootstrapContext) => bootstrapApplication(AppComponent, config, context);
+
+export default bootstrap;
diff --git a/docs/src/main.ts b/docs/src/main.ts
index 5283f27..9d78dfd 100644
--- a/docs/src/main.ts
+++ b/docs/src/main.ts
@@ -1,4 +1,3 @@
-import { provideZonelessChangeDetection } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
@@ -9,7 +8,12 @@ import { appConfig } from './app/app.config';
// to refresh the list of projects it carries), not whatever the CDN serves today.
import '@softwarity/projects';
-bootstrapApplication(AppComponent, {
- ...appConfig,
- providers: [provideZonelessChangeDetection(), ...appConfig.providers],
-}).catch((err) => console.error(err));
+// The site routed on the hash until it had a domain of its own, and those
+// addresses are still out there: in bookmarks, in other sites, in the messages
+// plug and its agent print. /#/kubernetes becomes /kubernetes, an anchor after it
+// included, before the router reads the address.
+if (location.hash.startsWith('#/')) {
+ history.replaceState(history.state, '', location.hash.slice(1));
+}
+
+bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err));
diff --git a/docs/src/root/robots.txt b/docs/src/root/robots.txt
new file mode 100644
index 0000000..d9f1988
--- /dev/null
+++ b/docs/src/root/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Allow: /
+
+Sitemap: https://plug.softwarity.dev/sitemap.xml
diff --git a/docs/tsconfig.app.json b/docs/tsconfig.app.json
index 5b9d3c5..d366ec9 100644
--- a/docs/tsconfig.app.json
+++ b/docs/tsconfig.app.json
@@ -4,6 +4,6 @@
"outDir": "./out-tsc/app",
"types": []
},
- "files": ["src/main.ts"],
+ "files": ["src/main.ts", "src/main.server.ts"],
"include": ["src/**/*.d.ts"]
}