feat(appstore): publish resolver client
This commit is contained in:
+16
@@ -0,0 +1,16 @@
|
|||||||
|
.nogit/
|
||||||
|
|
||||||
|
# installs
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# builds
|
||||||
|
dist/
|
||||||
|
dist_*/
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.cache/
|
||||||
|
.rpt2_cache
|
||||||
|
|
||||||
|
# AI
|
||||||
|
.claude/
|
||||||
|
.serena/
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"@git.zone/cli": {
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"projectType": "npm",
|
||||||
|
"module": {
|
||||||
|
"githost": "code.foss.global",
|
||||||
|
"gitscope": "serve.zone",
|
||||||
|
"gitrepo": "appstore",
|
||||||
|
"description": "serve.zone App Store metadata, parser, and resolver client.",
|
||||||
|
"npmPackagename": "@serve.zone/appstore",
|
||||||
|
"license": "MIT",
|
||||||
|
"projectDomain": "serve.zone",
|
||||||
|
"keywords": [
|
||||||
|
"serve.zone",
|
||||||
|
"appstore",
|
||||||
|
"catalog",
|
||||||
|
"templates",
|
||||||
|
"onebox",
|
||||||
|
"cloudly"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"targets": {
|
||||||
|
"git": {
|
||||||
|
"enabled": true,
|
||||||
|
"remote": "origin"
|
||||||
|
},
|
||||||
|
"npm": {
|
||||||
|
"enabled": true,
|
||||||
|
"registries": [
|
||||||
|
"https://verdaccio.lossless.digital",
|
||||||
|
"https://registry.npmjs.org"
|
||||||
|
],
|
||||||
|
"accessLevel": "public"
|
||||||
|
},
|
||||||
|
"docker": {
|
||||||
|
"enabled": false,
|
||||||
|
"engine": "tsdocker"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@ship.zone/szci": {}
|
||||||
|
}
|
||||||
@@ -144,7 +144,12 @@
|
|||||||
"category": "Dev Tools",
|
"category": "Dev Tools",
|
||||||
"iconName": "server",
|
"iconName": "server",
|
||||||
"latestVersion": "1.0.0",
|
"latestVersion": "1.0.0",
|
||||||
"tags": ["serve.zone", "control-plane", "clusters", "deployments"]
|
"tags": ["serve.zone", "control-plane", "clusters", "deployments"],
|
||||||
|
"source": {
|
||||||
|
"type": "repoManifest",
|
||||||
|
"url": "https://code.foss.global/serve.zone/cloudly/raw/branch/main/servezone.appstore.json",
|
||||||
|
"ref": "main"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "redis",
|
"id": "redis",
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## Pending
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- publish appstore resolver client
|
||||||
|
- Adds the `@serve.zone/appstore` package with a TypeScript resolver client
|
||||||
|
- Renames the top-level index to `appstore.json`
|
||||||
|
- Supports linked `servezone.appstore.json` manifests and Docker digest-tracked app upgrades
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "@serve.zone/appstore",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": false,
|
||||||
|
"description": "serve.zone App Store metadata, parser, and resolver client.",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./dist_ts_client/index.js"
|
||||||
|
},
|
||||||
|
"author": "Task Venture Capital GmbH",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"test": "tstest test/ --verbose --logfile --timeout 60",
|
||||||
|
"build": "tsbuild tsfolders",
|
||||||
|
"buildDocs": "tsdoc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@serve.zone/interfaces": "^6.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@git.zone/tsbuild": "^4.4.2",
|
||||||
|
"@git.zone/tsdoc": "^2.0.6",
|
||||||
|
"@git.zone/tstest": "^3.6.6",
|
||||||
|
"@types/node": "^25.8.0"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"apps/**/*",
|
||||||
|
"appstore.json",
|
||||||
|
"appstore.resolved.json",
|
||||||
|
"ts_client/**/*",
|
||||||
|
"dist_ts_client/**/*",
|
||||||
|
".smartconfig.json",
|
||||||
|
"readme.md",
|
||||||
|
"changelog.md",
|
||||||
|
"license"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://code.foss.global/serve.zone/appstore.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://code.foss.global/serve.zone/appstore/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://code.foss.global/serve.zone/appstore#readme",
|
||||||
|
"keywords": [
|
||||||
|
"serve.zone",
|
||||||
|
"appstore",
|
||||||
|
"templates",
|
||||||
|
"onebox",
|
||||||
|
"cloudly"
|
||||||
|
],
|
||||||
|
"packageManager": "pnpm@11.2.2"
|
||||||
|
}
|
||||||
Generated
+8029
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
mongodb-memory-server: false
|
||||||
|
puppeteer: false
|
||||||
|
sharp: false
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# serve.zone App Store App Templates
|
# @serve.zone/appstore
|
||||||
|
|
||||||
This repository is the curated template catalog consumed by the Onebox App Store. It is intentionally data-only: the repo contains catalog metadata plus per-app version configuration for deployable containers and their platform requirements.
|
This repository is the curated App Store index consumed by serve.zone runtimes. It publishes `@serve.zone/appstore`, a small TypeScript client that parses and resolves App Store metadata, linked `servezone.appstore.json` manifests, and Docker digest-tracked image sources.
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -10,22 +10,25 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
|
|
||||||
| File or directory | Purpose |
|
| File or directory | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `catalog.json` | Top-level catalog index with app IDs, display names, descriptions, categories, icons, latest versions, and tags. |
|
| `appstore.json` | Top-level App Store index with app IDs, display names, descriptions, categories, icons, latest versions, and tags. |
|
||||||
| `apps/<app>/app.json` | App-specific metadata, maintained version list, and optional links. |
|
| `apps/<app>/app.json` | App-specific metadata, maintained version list, and optional links. |
|
||||||
| `apps/<app>/versions/<version>/config.json` | Deployable version definition: image, internal port, environment variables, platform requirements, and compatibility metadata. |
|
| `apps/<app>/versions/<version>/config.json` | Deployable version definition: image, internal port, environment variables, platform requirements, and compatibility metadata. |
|
||||||
|
| `ts_client/` | Published `@serve.zone/appstore` parser and resolver client. |
|
||||||
|
| `source.type=repoManifest` | Optional link to a `servezone.appstore.json` file owned by the upstream app repo. |
|
||||||
|
|
||||||
No source code, package manifest, test runner, or build step exists here today. Changes are reviewed by reading the JSON data and by testing the affected templates in Onebox.
|
Changes are reviewed by reading the JSON data, running the client tests, and testing affected templates in Onebox or Cloudly.
|
||||||
|
|
||||||
## Current Catalog
|
## Current App Store
|
||||||
|
|
||||||
The catalog currently lists 18 app templates.
|
The App Store currently lists 20 app templates.
|
||||||
|
|
||||||
| App ID | Name | Catalog category | Image | Port |
|
| App ID | Name | App Store category | Image | Port |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `adminer` | Adminer | Dev Tools | `adminer:latest` | `8080` |
|
| `adminer` | Adminer | Dev Tools | `adminer:latest` | `8080` |
|
||||||
| `cloudly` | Cloudly | Dev Tools | `code.foss.global/serve.zone/cloudly:latest` | `80` |
|
| `cloudly` | Cloudly | Dev Tools | `code.foss.global/serve.zone/cloudly:latest` | `80` |
|
||||||
| `ghost` | Ghost | CMS | `ghost:latest` | `2368` |
|
| `ghost` | Ghost | CMS | `ghost:latest` | `2368` |
|
||||||
| `gitea` | Gitea | Dev Tools | `gitea/gitea:latest` | `3000` |
|
| `gitea` | Gitea | Dev Tools | `gitea/gitea:latest` | `3000` |
|
||||||
|
| `gitops` | GitOps | Dev Tools | `code.foss.global/serve.zone/gitops:2.13.0` | `3000` |
|
||||||
| `grafana` | Grafana | Monitoring | `grafana/grafana:latest` | `3000` |
|
| `grafana` | Grafana | Monitoring | `grafana/grafana:latest` | `3000` |
|
||||||
| `mariadb` | MariaDB | Database | `mariadb:latest` | `3306` |
|
| `mariadb` | MariaDB | Database | `mariadb:latest` | `3306` |
|
||||||
| `mattermost` | Mattermost | Communication | `mattermost/mattermost-team-edition:latest` | `8065` |
|
| `mattermost` | Mattermost | Communication | `mattermost/mattermost-team-edition:latest` | `8065` |
|
||||||
@@ -37,6 +40,7 @@ The catalog currently lists 18 app templates.
|
|||||||
| `postgres` | PostgreSQL | Database | `postgres:16-alpine` | `5432` |
|
| `postgres` | PostgreSQL | Database | `postgres:16-alpine` | `5432` |
|
||||||
| `redis` | Redis | Database | `redis:alpine` | `6379` |
|
| `redis` | Redis | Database | `redis:alpine` | `6379` |
|
||||||
| `rustdesk-server` | RustDesk Server | Remote Access | `rustdesk/rustdesk-server-s6:latest` | `21116` |
|
| `rustdesk-server` | RustDesk Server | Remote Access | `rustdesk/rustdesk-server-s6:latest` | `21116` |
|
||||||
|
| `siprouter` | SIP Router | Communication | `code.foss.global/serve.zone/siprouter:1.28.0` | `3060` |
|
||||||
| `uptime-kuma` | Uptime Kuma | Monitoring | `louislam/uptime-kuma:latest` | `3001` |
|
| `uptime-kuma` | Uptime Kuma | Monitoring | `louislam/uptime-kuma:latest` | `3001` |
|
||||||
| `vaultwarden` | Vaultwarden | Security | `vaultwarden/server:latest` | `80` |
|
| `vaultwarden` | Vaultwarden | Security | `vaultwarden/server:latest` | `80` |
|
||||||
| `wordpress` | WordPress | CMS | `wordpress:latest` | `80` |
|
| `wordpress` | WordPress | CMS | `wordpress:latest` | `80` |
|
||||||
@@ -52,13 +56,14 @@ Some templates ask Onebox to provision local platform services and inject connec
|
|||||||
| `gitea` | MariaDB | Uses Gitea database environment variables with `${MARIADB_*}` placeholders. |
|
| `gitea` | MariaDB | Uses Gitea database environment variables with `${MARIADB_*}` placeholders. |
|
||||||
| `nextcloud` | MariaDB, Redis | Uses MySQL-compatible database variables. Redis is declared as a platform requirement. |
|
| `nextcloud` | MariaDB, Redis | Uses MySQL-compatible database variables. Redis is declared as a platform requirement. |
|
||||||
| `plausible` | ClickHouse | Declares ClickHouse as a platform requirement. |
|
| `plausible` | ClickHouse | Declares ClickHouse as a platform requirement. |
|
||||||
|
| `siprouter` | MongoDB, S3 | Uses SmartData state and object storage for telephony-related runtime data. |
|
||||||
| `wordpress` | MariaDB | Uses standard WordPress database environment variables. |
|
| `wordpress` | MariaDB | Uses standard WordPress database environment variables. |
|
||||||
|
|
||||||
Standalone templates such as `nginx`, `adminer`, `redis`, `portainer`, `mattermost`, `n8n`, `uptime-kuma`, and `vaultwarden` currently define only image and port unless their version config adds explicit environment variables. `rustdesk-server` uses the official all-in-one s6 image and requires `RELAY` to advertise the public relay endpoint; RustDesk clients also require firewall access to TCP `21115`, TCP/UDP `21116`, TCP `21117`, and optional web-client TCP `21118`/`21119`.
|
Standalone templates such as `nginx`, `adminer`, `redis`, `portainer`, `mattermost`, `n8n`, `uptime-kuma`, and `vaultwarden` currently define only image and port unless their version config adds explicit environment variables. `rustdesk-server` uses the official all-in-one s6 image and requires `RELAY` to advertise the public relay endpoint; RustDesk clients also require firewall access to TCP `21115`, TCP/UDP `21116`, TCP `21117`, and optional web-client TCP `21118`/`21119`.
|
||||||
|
|
||||||
## Template Schema In Practice
|
## Template Schema In Practice
|
||||||
|
|
||||||
Version configs use a small, pragmatic schema understood by Onebox:
|
Version configs use a small, pragmatic schema understood by serve.zone runtimes:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -83,6 +88,45 @@ Version configs use a small, pragmatic schema understood by Onebox:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Linked app manifests use the same runtime schema and let the upstream app repo own its install and upgrade metadata:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"app": {
|
||||||
|
"id": "cloudly",
|
||||||
|
"name": "Cloudly",
|
||||||
|
"description": "Multi-node serve.zone control plane.",
|
||||||
|
"category": "Dev Tools"
|
||||||
|
},
|
||||||
|
"latestVersion": "latest",
|
||||||
|
"source": {
|
||||||
|
"type": "dockerImage",
|
||||||
|
"image": "code.foss.global/serve.zone/cloudly:latest",
|
||||||
|
"tracking": "digest"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"image": "code.foss.global/serve.zone/cloudly:latest",
|
||||||
|
"port": 80
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `tracking` is `digest`, Onebox resolves the Docker manifest digest and treats digest changes as App Store upgrades while keeping the image reference simple for deployment.
|
||||||
|
|
||||||
|
## Client Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { AppStoreResolver } from '@serve.zone/appstore';
|
||||||
|
|
||||||
|
const resolver = new AppStoreResolver();
|
||||||
|
const appStore = await resolver.getAppStoreIndex();
|
||||||
|
const cloudly = await resolver.getAppMeta('cloudly');
|
||||||
|
const config = await resolver.getAppVersionConfig('cloudly', cloudly.latestVersion);
|
||||||
|
```
|
||||||
|
|
||||||
|
The resolver defaults to `https://code.foss.global/serve.zone/appstore/raw/branch/main` and reads `appstore.resolved.json` first, then `appstore.json`. Tests can inject a custom `fetch` implementation and base URL.
|
||||||
|
|
||||||
Only include keys that are actually needed by a template. For example, `nginx` is currently just:
|
Only include keys that are actually needed by a template. For example, `nginx` is currently just:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -94,7 +138,8 @@ Only include keys that are actually needed by a template. For example, `nginx` i
|
|||||||
|
|
||||||
## Working With Templates
|
## Working With Templates
|
||||||
|
|
||||||
- Keep `catalog.json`, `apps/<app>/app.json`, and each version config in sync when adding or changing an app.
|
- Keep `appstore.json`, `apps/<app>/app.json`, and each version config in sync when adding or changing an app.
|
||||||
|
- Prefer `source.type=repoManifest` for serve.zone-owned apps so upgrades can be controlled by merging `servezone.appstore.json` changes into the app repo's main branch.
|
||||||
- Prefer explicit environment variable descriptions because Onebox surfaces them to users during installation.
|
- Prefer explicit environment variable descriptions because Onebox surfaces them to users during installation.
|
||||||
- Use platform placeholders such as `${MARIADB_HOST}`, `${MARIADB_PORT}`, `${MARIADB_DATABASE}`, `${MARIADB_USER}`, `${MARIADB_PASSWORD}`, `${MONGODB_URI}`, `${S3_BUCKET}`, `${S3_ACCESS_KEY}`, and `${S3_SECRET_KEY}` only when the matching platform service is declared.
|
- Use platform placeholders such as `${MARIADB_HOST}`, `${MARIADB_PORT}`, `${MARIADB_DATABASE}`, `${MARIADB_USER}`, `${MARIADB_PASSWORD}`, `${MONGODB_URI}`, `${S3_BUCKET}`, `${S3_ACCESS_KEY}`, and `${S3_SECRET_KEY}` only when the matching platform service is declared.
|
||||||
- Keep template images boring and operationally safe unless there is a reason to pin a more specific upstream tag.
|
- Keep template images boring and operationally safe unless there is a reason to pin a more specific upstream tag.
|
||||||
@@ -103,8 +148,9 @@ Only include keys that are actually needed by a template. For example, `nginx` i
|
|||||||
## Project Map
|
## Project Map
|
||||||
|
|
||||||
```text
|
```text
|
||||||
appstore-apptemplates/
|
appstore/
|
||||||
├── catalog.json
|
├── appstore.json
|
||||||
|
├── ts_client/
|
||||||
└── apps/
|
└── apps/
|
||||||
├── cloudly/
|
├── cloudly/
|
||||||
│ ├── app.json
|
│ ├── app.json
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import {
|
||||||
|
AppStoreResolver,
|
||||||
|
parseDockerImageReference,
|
||||||
|
parseServezoneAppStoreManifest,
|
||||||
|
} from '../ts_client/index.js';
|
||||||
|
|
||||||
|
tap.test('parses docker image references', async () => {
|
||||||
|
expect(parseDockerImageReference('nginx:alpine')).toEqual({
|
||||||
|
registry: 'registry-1.docker.io',
|
||||||
|
repository: 'library/nginx',
|
||||||
|
tag: 'alpine',
|
||||||
|
digest: undefined,
|
||||||
|
});
|
||||||
|
expect(parseDockerImageReference('code.foss.global/serve.zone/cloudly:latest')).toEqual({
|
||||||
|
registry: 'code.foss.global',
|
||||||
|
repository: 'serve.zone/cloudly',
|
||||||
|
tag: 'latest',
|
||||||
|
digest: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('validates servezone appstore manifests', async () => {
|
||||||
|
const manifest = parseServezoneAppStoreManifest({
|
||||||
|
schemaVersion: 1,
|
||||||
|
app: {
|
||||||
|
id: 'cloudly',
|
||||||
|
name: 'Cloudly',
|
||||||
|
description: 'Control plane',
|
||||||
|
category: 'Dev Tools',
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
image: 'code.foss.global/serve.zone/cloudly:latest',
|
||||||
|
port: 80,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manifest.app.id).toEqual('cloudly');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('resolves repo manifests and digest tracked images', async () => {
|
||||||
|
const baseUrl = 'https://appstore.example.test';
|
||||||
|
const manifestUrl = 'https://code.example.test/cloudly/servezone.appstore.json';
|
||||||
|
const digest = 'sha256:1234567890abcdef';
|
||||||
|
|
||||||
|
const fakeFetch: typeof fetch = async (inputArg, initArg) => {
|
||||||
|
const url = inputArg instanceof Request ? inputArg.url : inputArg.toString();
|
||||||
|
const method = initArg?.method || 'GET';
|
||||||
|
|
||||||
|
if (url === `${baseUrl}/appstore.resolved.json`) {
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === `${baseUrl}/appstore.json`) {
|
||||||
|
return Response.json({
|
||||||
|
schemaVersion: 1,
|
||||||
|
updatedAt: '2026-05-25T00:00:00Z',
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
id: 'cloudly',
|
||||||
|
name: 'Cloudly',
|
||||||
|
description: 'Curated listing metadata.',
|
||||||
|
category: 'Dev Tools',
|
||||||
|
latestVersion: 'latest',
|
||||||
|
source: {
|
||||||
|
type: 'repoManifest',
|
||||||
|
url: manifestUrl,
|
||||||
|
ref: 'main',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === manifestUrl) {
|
||||||
|
return Response.json({
|
||||||
|
schemaVersion: 1,
|
||||||
|
app: {
|
||||||
|
id: 'cloudly',
|
||||||
|
name: 'Cloudly',
|
||||||
|
description: 'Manifest-owned metadata.',
|
||||||
|
category: 'Dev Tools',
|
||||||
|
},
|
||||||
|
latestVersion: 'latest',
|
||||||
|
source: {
|
||||||
|
type: 'dockerImage',
|
||||||
|
image: 'registry.example.test/serve.zone/cloudly:latest',
|
||||||
|
tracking: 'digest',
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
image: 'registry.example.test/serve.zone/cloudly:latest',
|
||||||
|
port: 80,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url === 'https://registry.example.test/v2/serve.zone/cloudly/manifests/latest' &&
|
||||||
|
method === 'HEAD'
|
||||||
|
) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'docker-content-digest': digest },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(`unexpected ${method} ${url}`, { status: 500 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolver = new AppStoreResolver({
|
||||||
|
baseUrl,
|
||||||
|
fetch: fakeFetch,
|
||||||
|
now: () => new Date('2026-05-25T12:00:00Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const appStore = await resolver.getAppStoreIndex();
|
||||||
|
expect(appStore.apps[0].latestVersion).toEqual(`latest@${digest}`);
|
||||||
|
expect(appStore.apps[0].resolvedSource?.manifestHash?.length).toEqual(64);
|
||||||
|
expect(appStore.apps[0].upgradeStrategy).toEqual('dockerDigest');
|
||||||
|
|
||||||
|
const appMeta = await resolver.getAppMeta('cloudly');
|
||||||
|
expect(appMeta.latestVersion).toEqual(`latest@${digest}`);
|
||||||
|
expect(appMeta.versions).toEqual([`latest@${digest}`]);
|
||||||
|
|
||||||
|
const config = await resolver.getAppVersionConfig('cloudly', appMeta.latestVersion);
|
||||||
|
expect(config.image).toEqual('registry.example.test/serve.zone/cloudly:latest');
|
||||||
|
expect(config.appStoreVersion).toEqual(`latest@${digest}`);
|
||||||
|
expect(config.resolvedImageDigest).toEqual(digest);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,623 @@
|
|||||||
|
import type * as interfaces from '@serve.zone/interfaces';
|
||||||
|
import type {
|
||||||
|
IAppStoreResolverOptions,
|
||||||
|
IParsedDockerImageReference,
|
||||||
|
IResolvedAppStoreApp,
|
||||||
|
TAppStoreApp,
|
||||||
|
TAppStoreDockerImageSource,
|
||||||
|
TAppStoreIndex,
|
||||||
|
TAppStoreRepoManifestSource,
|
||||||
|
TAppStoreResolvedSource,
|
||||||
|
TAppStoreVersionConfig,
|
||||||
|
TServezoneAppStoreManifest,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export class AppStoreResolver {
|
||||||
|
public readonly baseUrl: string;
|
||||||
|
private readonly fetchRef: typeof fetch;
|
||||||
|
private readonly resolveDockerDigests: boolean;
|
||||||
|
private readonly now: () => Date;
|
||||||
|
private appStoreCache: TAppStoreIndex | null = null;
|
||||||
|
private sourceAppCache = new Map<string, IResolvedAppStoreApp>();
|
||||||
|
|
||||||
|
constructor(optionsArg: IAppStoreResolverOptions = {}) {
|
||||||
|
this.baseUrl = optionsArg.baseUrl || 'https://code.foss.global/serve.zone/appstore/raw/branch/main';
|
||||||
|
this.fetchRef = optionsArg.fetch || fetch;
|
||||||
|
this.resolveDockerDigests = optionsArg.resolveDockerDigests ?? true;
|
||||||
|
this.now = optionsArg.now || (() => new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAppStoreIndex(): Promise<TAppStoreIndex> {
|
||||||
|
if (this.appStoreCache) {
|
||||||
|
return this.appStoreCache;
|
||||||
|
}
|
||||||
|
const appStore = await this.fetchAppStoreIndex();
|
||||||
|
this.appStoreCache = await this.resolveAppStore(appStore);
|
||||||
|
return this.appStoreCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getApps(): Promise<TAppStoreApp[]> {
|
||||||
|
return (await this.getAppStoreIndex()).apps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAppMeta(appIdArg: string): Promise<interfaces.appstore.IAppStoreAppMeta> {
|
||||||
|
const app = await this.getAppStoreApp(appIdArg);
|
||||||
|
if (app?.source?.type === 'repoManifest') {
|
||||||
|
return (await this.resolveRepoManifestSource(app.source, app)).appMeta;
|
||||||
|
}
|
||||||
|
if (app?.source?.type === 'dockerImage') {
|
||||||
|
return this.createAppMetaFromAppStoreApp(app);
|
||||||
|
}
|
||||||
|
return await this.fetchJson(`apps/${appIdArg}/app.json`) as interfaces.appstore.IAppStoreAppMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAppVersionConfig(
|
||||||
|
appIdArg: string,
|
||||||
|
versionArg: string,
|
||||||
|
): Promise<TAppStoreVersionConfig> {
|
||||||
|
const app = await this.getAppStoreApp(appIdArg);
|
||||||
|
if (app?.source?.type === 'repoManifest') {
|
||||||
|
const resolvedApp = await this.resolveRepoManifestSource(app.source, app);
|
||||||
|
const config = resolvedApp.configsByVersion.get(versionArg);
|
||||||
|
if (!config) {
|
||||||
|
throw new Error(`Version '${versionArg}' is not defined by the linked appstore manifest`);
|
||||||
|
}
|
||||||
|
this.validateAppStoreVersionConfig(config, `${appIdArg}@${versionArg}`);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app?.source?.type === 'dockerImage' && app.runtime) {
|
||||||
|
const config: TAppStoreVersionConfig = { ...app.runtime };
|
||||||
|
await this.applyDockerImageSourceToConfig(app.source, config, versionArg);
|
||||||
|
this.validateAppStoreVersionConfig(config, `${appIdArg}@${versionArg}`);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
let config: TAppStoreVersionConfig;
|
||||||
|
try {
|
||||||
|
config = await this.fetchJson(`apps/${appIdArg}/versions/${versionArg}/config.json`) as TAppStoreVersionConfig;
|
||||||
|
} catch (error) {
|
||||||
|
if (app?.source?.type !== 'dockerImage') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const appMeta = await this.fetchJson(`apps/${appIdArg}/app.json`) as interfaces.appstore.IAppStoreAppMeta;
|
||||||
|
config = await this.fetchJson(`apps/${appIdArg}/versions/${appMeta.latestVersion}/config.json`) as TAppStoreVersionConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app?.source?.type === 'dockerImage') {
|
||||||
|
await this.applyDockerImageSourceToConfig(app.source, config, versionArg);
|
||||||
|
}
|
||||||
|
this.validateAppStoreVersionConfig(config, `${appIdArg}@${versionArg}`);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resolveAppStore(appStoreArg: unknown): Promise<TAppStoreIndex> {
|
||||||
|
const appStore = parseAppStoreIndex(appStoreArg);
|
||||||
|
this.sourceAppCache.clear();
|
||||||
|
const apps: TAppStoreApp[] = [];
|
||||||
|
|
||||||
|
for (const app of appStore.apps) {
|
||||||
|
apps.push(await this.resolveAppStoreApp(app));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...appStore,
|
||||||
|
apps,
|
||||||
|
resolvedAt: this.now().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resolveRepoManifestSource(
|
||||||
|
sourceArg: TAppStoreRepoManifestSource,
|
||||||
|
appArg?: TAppStoreApp,
|
||||||
|
): Promise<IResolvedAppStoreApp> {
|
||||||
|
const cacheKey = `${sourceArg.url}#${sourceArg.ref || ''}`;
|
||||||
|
const cachedApp = this.sourceAppCache.get(cacheKey);
|
||||||
|
if (cachedApp) {
|
||||||
|
return cachedApp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestText = await this.fetchTextFromUrl(sourceArg.url);
|
||||||
|
const manifestHash = await createSha256Hex(manifestText);
|
||||||
|
const manifest = parseServezoneAppStoreManifest(JSON.parse(manifestText));
|
||||||
|
const resolvedApp = await this.resolveServezoneAppStoreManifest(manifest, {
|
||||||
|
type: 'repoManifest',
|
||||||
|
url: sourceArg.url,
|
||||||
|
ref: sourceArg.ref,
|
||||||
|
manifestHash,
|
||||||
|
resolvedAt: this.now().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (appArg) {
|
||||||
|
resolvedApp.appStoreApp = {
|
||||||
|
...resolvedApp.appStoreApp,
|
||||||
|
...withoutUndefined(appArg),
|
||||||
|
latestVersion: resolvedApp.appStoreApp.latestVersion,
|
||||||
|
versions: resolvedApp.appStoreApp.versions,
|
||||||
|
source: appArg.source,
|
||||||
|
tags: appArg.tags || resolvedApp.appStoreApp.tags,
|
||||||
|
resolvedSource: resolvedApp.appStoreApp.resolvedSource,
|
||||||
|
};
|
||||||
|
resolvedApp.appMeta = {
|
||||||
|
...resolvedApp.appMeta,
|
||||||
|
id: resolvedApp.appStoreApp.id,
|
||||||
|
name: resolvedApp.appStoreApp.name,
|
||||||
|
description: resolvedApp.appStoreApp.description,
|
||||||
|
category: resolvedApp.appStoreApp.category,
|
||||||
|
iconName: resolvedApp.appStoreApp.iconName,
|
||||||
|
latestVersion: resolvedApp.appStoreApp.latestVersion,
|
||||||
|
versions: resolvedApp.appStoreApp.versions || resolvedApp.appMeta.versions,
|
||||||
|
tags: resolvedApp.appStoreApp.tags,
|
||||||
|
source: appArg.source,
|
||||||
|
resolvedSource: resolvedApp.appStoreApp.resolvedSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sourceAppCache.set(cacheKey, resolvedApp);
|
||||||
|
return resolvedApp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resolveDockerImageSource(
|
||||||
|
sourceArg: TAppStoreDockerImageSource,
|
||||||
|
): Promise<TAppStoreResolvedSource> {
|
||||||
|
let imageDigest: string | undefined;
|
||||||
|
if (sourceArg.tracking === 'digest' && this.resolveDockerDigests) {
|
||||||
|
imageDigest = await this.resolveDockerImageDigest(sourceArg.image) || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'dockerImage',
|
||||||
|
image: sourceArg.image,
|
||||||
|
imageDigest,
|
||||||
|
resolvedAt: this.now().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resolveDockerImageDigest(imageArg: string): Promise<string | null> {
|
||||||
|
const parsedImage = parseDockerImageReference(imageArg);
|
||||||
|
if (parsedImage.digest) {
|
||||||
|
return parsedImage.digest;
|
||||||
|
}
|
||||||
|
return await this.fetchDockerManifestDigest(parsedImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public validateAppStoreVersionConfig(
|
||||||
|
configArg: TAppStoreVersionConfig,
|
||||||
|
labelArg = 'appstore config',
|
||||||
|
): void {
|
||||||
|
if (!configArg || typeof configArg !== 'object') {
|
||||||
|
throw new Error(`Invalid ${labelArg}: config must be an object`);
|
||||||
|
}
|
||||||
|
if (!configArg.image || typeof configArg.image !== 'string') {
|
||||||
|
throw new Error(`Invalid ${labelArg}: image is required`);
|
||||||
|
}
|
||||||
|
this.assertValidPort(configArg.port, `${labelArg} port`);
|
||||||
|
|
||||||
|
for (const envVar of configArg.envVars || []) {
|
||||||
|
if (!envVar.key || !/^[A-Z_][A-Z0-9_]*$/.test(envVar.key)) {
|
||||||
|
throw new Error(`Invalid ${labelArg}: env var key '${envVar.key}' is not valid`);
|
||||||
|
}
|
||||||
|
if (envVar.value !== undefined && typeof envVar.value !== 'string') {
|
||||||
|
throw new Error(`Invalid ${labelArg}: env var '${envVar.key}' value must be a string`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.normalizeVolumes(configArg.volumes);
|
||||||
|
this.validatePublishedPorts(configArg.publishedPorts || [], labelArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public normalizeVolumes(
|
||||||
|
volumesArg: TAppStoreVersionConfig['volumes'] = [],
|
||||||
|
): interfaces.appstore.IAppStoreVolume[] {
|
||||||
|
return volumesArg.map((volumeArg): interfaces.appstore.IAppStoreVolume => {
|
||||||
|
if (typeof volumeArg === 'string') {
|
||||||
|
return { mountPath: volumeArg };
|
||||||
|
}
|
||||||
|
return volumeArg;
|
||||||
|
}).map((volumeArg, indexArg) => {
|
||||||
|
this.validateVolume(volumeArg, `volume ${indexArg + 1}`);
|
||||||
|
return volumeArg;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchAppStoreIndex(): Promise<TAppStoreIndex> {
|
||||||
|
try {
|
||||||
|
return await this.fetchJson('appstore.resolved.json') as TAppStoreIndex;
|
||||||
|
} catch {
|
||||||
|
return await this.fetchJson('appstore.json') as TAppStoreIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveAppStoreApp(appArg: TAppStoreApp): Promise<TAppStoreApp> {
|
||||||
|
if (appArg.source?.type === 'repoManifest') {
|
||||||
|
const resolvedApp = await this.resolveRepoManifestSource(appArg.source, appArg);
|
||||||
|
return {
|
||||||
|
...resolvedApp.appStoreApp,
|
||||||
|
...withoutUndefined(appArg),
|
||||||
|
latestVersion: resolvedApp.appStoreApp.latestVersion,
|
||||||
|
versions: resolvedApp.appStoreApp.versions,
|
||||||
|
source: appArg.source,
|
||||||
|
tags: appArg.tags || resolvedApp.appStoreApp.tags,
|
||||||
|
resolvedSource: resolvedApp.appStoreApp.resolvedSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appArg.source?.type === 'dockerImage') {
|
||||||
|
const config = appArg.runtime ? { ...appArg.runtime } : undefined;
|
||||||
|
const resolvedSource = config
|
||||||
|
? (await this.applyDockerImageSourceToConfig(appArg.source, config, appArg.latestVersion)).resolvedSource
|
||||||
|
: await this.resolveDockerImageSource(appArg.source);
|
||||||
|
const latestVersion = createAppStoreVersionForDockerSource(
|
||||||
|
appArg.source,
|
||||||
|
appArg.latestVersion,
|
||||||
|
resolvedSource?.imageDigest,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...appArg,
|
||||||
|
runtime: config,
|
||||||
|
latestVersion,
|
||||||
|
versions: uniqueStrings([...(appArg.versions || []), latestVersion]),
|
||||||
|
upgradeStrategy: appArg.source.tracking === 'digest' ? 'dockerDigest' : appArg.upgradeStrategy,
|
||||||
|
resolvedSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return appArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveServezoneAppStoreManifest(
|
||||||
|
manifestArg: TServezoneAppStoreManifest,
|
||||||
|
resolvedSourceArg: TAppStoreResolvedSource,
|
||||||
|
): Promise<IResolvedAppStoreApp> {
|
||||||
|
const configsByVersion = new Map<string, TAppStoreVersionConfig>();
|
||||||
|
const versions: string[] = [];
|
||||||
|
const sourceVersionToResolvedVersion = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const versionArg of manifestArg.versions || []) {
|
||||||
|
const sourceVersion = versionArg.version;
|
||||||
|
const { version: _version, ...versionConfig } = versionArg;
|
||||||
|
const config: TAppStoreVersionConfig = {
|
||||||
|
...versionConfig,
|
||||||
|
source: versionConfig.source || manifestArg.source,
|
||||||
|
resolvedSource: resolvedSourceArg,
|
||||||
|
};
|
||||||
|
await this.resolveConfigSource(config, sourceVersion);
|
||||||
|
const resolvedVersion = config.appStoreVersion || sourceVersion;
|
||||||
|
config.appStoreVersion = resolvedVersion;
|
||||||
|
this.validateAppStoreVersionConfig(config, `${manifestArg.app.id}@${resolvedVersion}`);
|
||||||
|
configsByVersion.set(resolvedVersion, config);
|
||||||
|
configsByVersion.set(sourceVersion, config);
|
||||||
|
versions.push(resolvedVersion);
|
||||||
|
sourceVersionToResolvedVersion.set(sourceVersion, resolvedVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifestArg.runtime) {
|
||||||
|
const sourceVersion = manifestArg.latestVersion || manifestArg.channel || 'latest';
|
||||||
|
const config: TAppStoreVersionConfig = {
|
||||||
|
...manifestArg.runtime,
|
||||||
|
source: manifestArg.runtime.source || manifestArg.source,
|
||||||
|
resolvedSource: resolvedSourceArg,
|
||||||
|
};
|
||||||
|
await this.resolveConfigSource(config, sourceVersion);
|
||||||
|
const resolvedVersion = config.appStoreVersion || sourceVersion;
|
||||||
|
config.appStoreVersion = resolvedVersion;
|
||||||
|
this.validateAppStoreVersionConfig(config, `${manifestArg.app.id}@${resolvedVersion}`);
|
||||||
|
configsByVersion.set(resolvedVersion, config);
|
||||||
|
configsByVersion.set(sourceVersion, config);
|
||||||
|
versions.push(resolvedVersion);
|
||||||
|
sourceVersionToResolvedVersion.set(sourceVersion, resolvedVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configsByVersion.size === 0) {
|
||||||
|
throw new Error('Appstore manifest must define at least one runtime config or version');
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedChannel = manifestArg.policy?.defaultChannel || manifestArg.channel || 'stable';
|
||||||
|
const channelVersion = manifestArg.channels?.[selectedChannel];
|
||||||
|
const declaredLatestVersion = manifestArg.latestVersion || channelVersion || versions[versions.length - 1];
|
||||||
|
const latestVersion = sourceVersionToResolvedVersion.get(declaredLatestVersion) || declaredLatestVersion;
|
||||||
|
const uniqueVersions = uniqueStrings(versions);
|
||||||
|
|
||||||
|
const appStoreApp: TAppStoreApp = {
|
||||||
|
id: manifestArg.app.id,
|
||||||
|
name: manifestArg.app.name,
|
||||||
|
description: manifestArg.app.description,
|
||||||
|
category: manifestArg.app.category,
|
||||||
|
iconName: manifestArg.app.iconName,
|
||||||
|
iconUrl: manifestArg.app.iconUrl,
|
||||||
|
latestVersion,
|
||||||
|
versions: uniqueVersions,
|
||||||
|
tags: manifestArg.app.tags,
|
||||||
|
channel: selectedChannel,
|
||||||
|
source: manifestArg.source,
|
||||||
|
upgradeStrategy: getUpgradeStrategyForConfig(configsByVersion.get(latestVersion)),
|
||||||
|
resolvedSource: resolvedSourceArg,
|
||||||
|
};
|
||||||
|
|
||||||
|
const appMeta: interfaces.appstore.IAppStoreAppMeta = {
|
||||||
|
id: manifestArg.app.id,
|
||||||
|
name: manifestArg.app.name,
|
||||||
|
description: manifestArg.app.description,
|
||||||
|
category: manifestArg.app.category,
|
||||||
|
iconName: manifestArg.app.iconName,
|
||||||
|
latestVersion,
|
||||||
|
versions: uniqueVersions,
|
||||||
|
maintainer: manifestArg.app.maintainer,
|
||||||
|
links: manifestArg.app.links,
|
||||||
|
tags: manifestArg.app.tags,
|
||||||
|
source: manifestArg.source,
|
||||||
|
resolvedSource: resolvedSourceArg,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { appStoreApp, appMeta, configsByVersion };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveConfigSource(configArg: TAppStoreVersionConfig, versionArg: string): Promise<void> {
|
||||||
|
if (configArg.source?.type === 'dockerImage') {
|
||||||
|
await this.applyDockerImageSourceToConfig(configArg.source, configArg, versionArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyDockerImageSourceToConfig(
|
||||||
|
sourceArg: TAppStoreDockerImageSource,
|
||||||
|
configArg: TAppStoreVersionConfig,
|
||||||
|
versionArg: string,
|
||||||
|
): Promise<TAppStoreVersionConfig> {
|
||||||
|
configArg.image = sourceArg.image;
|
||||||
|
configArg.source = sourceArg;
|
||||||
|
|
||||||
|
const resolvedSource = await this.resolveDockerImageSource(sourceArg);
|
||||||
|
configArg.resolvedSource = resolvedSource;
|
||||||
|
configArg.resolvedImageDigest = resolvedSource.imageDigest;
|
||||||
|
configArg.upgradeStrategy = sourceArg.tracking === 'digest' ? 'dockerDigest' : configArg.upgradeStrategy;
|
||||||
|
configArg.appStoreVersion = createAppStoreVersionForDockerSource(
|
||||||
|
sourceArg,
|
||||||
|
versionArg,
|
||||||
|
resolvedSource.imageDigest,
|
||||||
|
);
|
||||||
|
|
||||||
|
return configArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createAppMetaFromAppStoreApp(appArg: TAppStoreApp): interfaces.appstore.IAppStoreAppMeta {
|
||||||
|
return {
|
||||||
|
id: appArg.id,
|
||||||
|
name: appArg.name,
|
||||||
|
description: appArg.description,
|
||||||
|
category: appArg.category,
|
||||||
|
iconName: appArg.iconName,
|
||||||
|
latestVersion: appArg.latestVersion,
|
||||||
|
versions: appArg.versions || [appArg.latestVersion],
|
||||||
|
tags: appArg.tags,
|
||||||
|
source: appArg.source,
|
||||||
|
resolvedSource: appArg.resolvedSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAppStoreApp(appIdArg: string): Promise<TAppStoreApp | undefined> {
|
||||||
|
const appStore = await this.getAppStoreIndex();
|
||||||
|
return appStore.apps.find((appArg) => appArg.id === appIdArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson(pathArg: string): Promise<unknown> {
|
||||||
|
const url = `${this.baseUrl}/${pathArg}`;
|
||||||
|
const response = await this.fetchRef(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status} for ${url}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchTextFromUrl(urlArg: string): Promise<string> {
|
||||||
|
const response = await this.fetchRef(urlArg);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status} for ${urlArg}`);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchDockerManifestDigest(imageArg: IParsedDockerImageReference): Promise<string | null> {
|
||||||
|
const manifestUrl = `https://${imageArg.registry}/v2/${imageArg.repository}/manifests/${imageArg.tag}`;
|
||||||
|
const headers = new Headers({
|
||||||
|
Accept: [
|
||||||
|
'application/vnd.docker.distribution.manifest.v2+json',
|
||||||
|
'application/vnd.oci.image.manifest.v1+json',
|
||||||
|
'application/vnd.docker.distribution.manifest.list.v2+json',
|
||||||
|
'application/vnd.oci.image.index.v1+json',
|
||||||
|
].join(', '),
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = await this.fetchRef(manifestUrl, { method: 'HEAD', headers });
|
||||||
|
if (response.status === 401) {
|
||||||
|
const authHeader = response.headers.get('www-authenticate');
|
||||||
|
const token = authHeader ? await this.fetchDockerRegistryToken(authHeader, imageArg.repository) : null;
|
||||||
|
if (token) {
|
||||||
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
|
response = await this.fetchRef(manifestUrl, { method: 'HEAD', headers });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !response.headers.get('docker-content-digest')) {
|
||||||
|
response = await this.fetchRef(manifestUrl, { method: 'GET', headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status} while resolving ${imageArg.repository}:${imageArg.tag}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.headers.get('docker-content-digest');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchDockerRegistryToken(authHeaderArg: string, repositoryArg: string): Promise<string | null> {
|
||||||
|
const match = authHeaderArg.match(/^Bearer\s+(.+)$/i);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const authParams = new Map<string, string>();
|
||||||
|
for (const partArg of match[1].match(/(?:[^,\"]+|\"[^\"]*\")+/g) || []) {
|
||||||
|
const [key, rawValue] = partArg.split('=');
|
||||||
|
if (!key || rawValue === undefined) continue;
|
||||||
|
authParams.set(key.trim(), rawValue.trim().replace(/^\"|\"$/g, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
const realm = authParams.get('realm');
|
||||||
|
if (!realm) return null;
|
||||||
|
const tokenUrl = new URL(realm);
|
||||||
|
const service = authParams.get('service');
|
||||||
|
const scope = authParams.get('scope') || `repository:${repositoryArg}:pull`;
|
||||||
|
if (service) tokenUrl.searchParams.set('service', service);
|
||||||
|
tokenUrl.searchParams.set('scope', scope);
|
||||||
|
|
||||||
|
const response = await this.fetchRef(tokenUrl.toString());
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const tokenResponse = await response.json() as { token?: string; access_token?: string };
|
||||||
|
return tokenResponse.token || tokenResponse.access_token || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateVolume(volumeArg: interfaces.appstore.IAppStoreVolume, labelArg: string): void {
|
||||||
|
if (!volumeArg.mountPath || !volumeArg.mountPath.startsWith('/')) {
|
||||||
|
throw new Error(`Invalid ${labelArg}: mountPath must be an absolute path`);
|
||||||
|
}
|
||||||
|
if (volumeArg.mountPath.includes(':')) {
|
||||||
|
throw new Error(`Invalid ${labelArg}: mountPath must not contain ':'`);
|
||||||
|
}
|
||||||
|
if ((volumeArg.source || volumeArg.name)?.includes(':')) {
|
||||||
|
throw new Error(`Invalid ${labelArg}: source/name must not contain ':'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validatePublishedPorts(
|
||||||
|
publishedPortsArg: TAppStoreVersionConfig['publishedPorts'] = [],
|
||||||
|
labelArg: string,
|
||||||
|
): void {
|
||||||
|
const seenPublishedPorts = new Set<string>();
|
||||||
|
for (const portArg of publishedPortsArg) {
|
||||||
|
const protocol = portArg.protocol || 'tcp';
|
||||||
|
const targetStart = portArg.targetPort;
|
||||||
|
const targetEnd = portArg.targetPortEnd || targetStart;
|
||||||
|
const publishedStart = portArg.publishedPort || targetStart;
|
||||||
|
const publishedEnd = portArg.publishedPortEnd || (publishedStart + (targetEnd - targetStart));
|
||||||
|
const hostIp = portArg.hostIp || '0.0.0.0';
|
||||||
|
|
||||||
|
if (!['tcp', 'udp'].includes(protocol)) {
|
||||||
|
throw new Error(`Invalid ${labelArg}: published port protocol '${protocol}' is not supported`);
|
||||||
|
}
|
||||||
|
this.assertValidPort(targetStart, `${labelArg} targetPort`);
|
||||||
|
this.assertValidPort(targetEnd, `${labelArg} targetPortEnd`);
|
||||||
|
this.assertValidPort(publishedStart, `${labelArg} publishedPort`);
|
||||||
|
this.assertValidPort(publishedEnd, `${labelArg} publishedPortEnd`);
|
||||||
|
if (targetEnd < targetStart || publishedEnd < publishedStart) {
|
||||||
|
throw new Error(`Invalid ${labelArg}: published port ranges must be ascending`);
|
||||||
|
}
|
||||||
|
if ((targetEnd - targetStart) !== (publishedEnd - publishedStart)) {
|
||||||
|
throw new Error(`Invalid ${labelArg}: target and published port ranges must have the same size`);
|
||||||
|
}
|
||||||
|
if ((targetEnd - targetStart) > 1000) {
|
||||||
|
throw new Error(`Invalid ${labelArg}: published port ranges may not exceed 1001 ports`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let offset = 0; offset <= targetEnd - targetStart; offset++) {
|
||||||
|
const publishedPort = publishedStart + offset;
|
||||||
|
const publishedKey = `${hostIp}/${protocol}/${publishedPort}`;
|
||||||
|
const wildcardKey = `0.0.0.0/${protocol}/${publishedPort}`;
|
||||||
|
const conflictsWithWildcard = hostIp === '0.0.0.0'
|
||||||
|
? Array.from(seenPublishedPorts).some((keyArg) => keyArg.endsWith(`/${protocol}/${publishedPort}`))
|
||||||
|
: seenPublishedPorts.has(wildcardKey);
|
||||||
|
if (seenPublishedPorts.has(publishedKey) || conflictsWithWildcard) {
|
||||||
|
throw new Error(`Invalid ${labelArg}: duplicate published port ${hostIp}:${publishedPort}/${protocol}`);
|
||||||
|
}
|
||||||
|
seenPublishedPorts.add(publishedKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertValidPort(portArg: number, labelArg: string): void {
|
||||||
|
if (!Number.isInteger(portArg) || portArg < 1 || portArg > 65535) {
|
||||||
|
throw new Error(`Invalid ${labelArg}: ${portArg}. Expected an integer port between 1 and 65535.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAppStoreIndex(inputArg: unknown): TAppStoreIndex {
|
||||||
|
const appStore = inputArg as TAppStoreIndex;
|
||||||
|
if (!appStore || typeof appStore !== 'object' || !Array.isArray(appStore.apps)) {
|
||||||
|
throw new Error('Invalid appstore format');
|
||||||
|
}
|
||||||
|
return appStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseServezoneAppStoreManifest(inputArg: unknown): TServezoneAppStoreManifest {
|
||||||
|
const manifest = inputArg as TServezoneAppStoreManifest;
|
||||||
|
if (!manifest || typeof manifest !== 'object') {
|
||||||
|
throw new Error('Appstore manifest must be an object');
|
||||||
|
}
|
||||||
|
if (manifest.schemaVersion !== 1) {
|
||||||
|
throw new Error(`Unsupported appstore manifest schemaVersion '${manifest.schemaVersion}'`);
|
||||||
|
}
|
||||||
|
if (!manifest.app?.id || !manifest.app?.name) {
|
||||||
|
throw new Error('Appstore manifest app.id and app.name are required');
|
||||||
|
}
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDockerImageReference(imageArg: string): IParsedDockerImageReference {
|
||||||
|
const [imageWithoutDigest, digest] = imageArg.split('@');
|
||||||
|
const imageParts = imageWithoutDigest.split('/');
|
||||||
|
const firstPart = imageParts[0];
|
||||||
|
const hasExplicitRegistry = imageParts.length > 1
|
||||||
|
&& (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost');
|
||||||
|
const registry = hasExplicitRegistry ? firstPart : 'registry-1.docker.io';
|
||||||
|
const repositoryParts = hasExplicitRegistry ? imageParts.slice(1) : imageParts;
|
||||||
|
let repositoryWithTag = repositoryParts.join('/');
|
||||||
|
if (!repositoryWithTag) {
|
||||||
|
throw new Error(`Invalid Docker image reference '${imageArg}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasExplicitRegistry && !repositoryWithTag.includes('/')) {
|
||||||
|
repositoryWithTag = `library/${repositoryWithTag}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSlashIndex = repositoryWithTag.lastIndexOf('/');
|
||||||
|
const lastColonIndex = repositoryWithTag.lastIndexOf(':');
|
||||||
|
const hasTag = lastColonIndex > lastSlashIndex;
|
||||||
|
const repository = hasTag ? repositoryWithTag.slice(0, lastColonIndex) : repositoryWithTag;
|
||||||
|
const tag = hasTag ? repositoryWithTag.slice(lastColonIndex + 1) : 'latest';
|
||||||
|
|
||||||
|
return { registry, repository, tag, digest };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAppStoreVersionForDockerSource(
|
||||||
|
sourceArg: TAppStoreDockerImageSource,
|
||||||
|
fallbackVersionArg: string,
|
||||||
|
digestArg?: string,
|
||||||
|
): string {
|
||||||
|
if (sourceArg.tracking !== 'digest' || !digestArg) {
|
||||||
|
return fallbackVersionArg;
|
||||||
|
}
|
||||||
|
const parsedImage = parseDockerImageReference(sourceArg.image);
|
||||||
|
return `${parsedImage.tag}@${digestArg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSha256Hex(inputArg: string): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(inputArg));
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((byteArg) => byteArg.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueStrings(valuesArg: string[]): string[] {
|
||||||
|
return Array.from(new Set(valuesArg.filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutUndefined<T extends object>(objectArg: T): Partial<T> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(objectArg).filter(([, valueArg]) => valueArg !== undefined),
|
||||||
|
) as Partial<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUpgradeStrategyForConfig(
|
||||||
|
configArg?: TAppStoreVersionConfig,
|
||||||
|
): interfaces.appstore.IAppStoreApp['upgradeStrategy'] {
|
||||||
|
if (configArg?.upgradeStrategy) return configArg.upgradeStrategy;
|
||||||
|
if (configArg?.source?.type === 'dockerImage' && configArg.source.tracking === 'digest') return 'dockerDigest';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './classes.appstoreresolver.js';
|
||||||
|
export * from './types.js';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"order": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type * as interfaces from '@serve.zone/interfaces';
|
||||||
|
|
||||||
|
export type TAppStoreFetch = typeof fetch;
|
||||||
|
export type TAppStoreIndex = interfaces.appstore.IAppStoreIndex;
|
||||||
|
export type TAppStoreApp = interfaces.appstore.IAppStoreApp;
|
||||||
|
export type TAppStoreAppMeta = interfaces.appstore.IAppStoreAppMeta;
|
||||||
|
export type TAppStoreVersionConfig = interfaces.appstore.IAppStoreVersionConfig;
|
||||||
|
export type TAppStoreDockerImageSource = interfaces.appstore.IAppStoreDockerImageSource;
|
||||||
|
export type TAppStoreRepoManifestSource = interfaces.appstore.IAppStoreRepoManifestSource;
|
||||||
|
export type TAppStoreResolvedSource = interfaces.appstore.IAppStoreResolvedSource;
|
||||||
|
export type TServezoneAppStoreManifest = interfaces.appstore.IServezoneAppStoreManifest;
|
||||||
|
|
||||||
|
export interface IAppStoreResolverOptions {
|
||||||
|
baseUrl?: string;
|
||||||
|
fetch?: TAppStoreFetch;
|
||||||
|
resolveDockerDigests?: boolean;
|
||||||
|
now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IResolvedAppStoreApp {
|
||||||
|
appStoreApp: TAppStoreApp;
|
||||||
|
appMeta: TAppStoreAppMeta;
|
||||||
|
configsByVersion: Map<string, TAppStoreVersionConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IParsedDockerImageReference {
|
||||||
|
registry: string;
|
||||||
|
repository: string;
|
||||||
|
tag: string;
|
||||||
|
digest?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": false,
|
||||||
|
"outDir": "dist_ts_client",
|
||||||
|
"strict": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"skipLibCheck": false,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"ts_client/**/*.ts",
|
||||||
|
"test/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user