diff --git a/public/schemas/workspace/v1.json b/public/schemas/workspace/v1.json new file mode 100644 index 000000000..3e8feb67f --- /dev/null +++ b/public/schemas/workspace/v1.json @@ -0,0 +1,111 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://toit.io/schemas/artemis/workspace/v1.json", + "title": "Artemis workspace", + "description": "Configures reusable servers and backend strategies for an Artemis workspace.", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "servers", + "backends" + ], + "properties": { + "$schema": { + "const": "https://toit.io/schemas/artemis/workspace/v1.json" + }, + "servers": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { "$ref": "#/$defs/SupabaseServer" }, + { "$ref": "#/$defs/HttpServer" } + ] + } + }, + "backends": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { "$ref": "#/$defs/FileBackend" }, + { "$ref": "#/$defs/HttpBackend" } + ] + } + } + }, + "$defs": { + "SupabaseServer": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "url", + "anon" + ], + "properties": { + "type": { "const": "supabase" }, + "url": { "type": "string", "format": "uri" }, + "anon": { "type": "string" }, + "root_certificate_ders64": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "HttpServer": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "url" + ], + "properties": { + "type": { "const": "toit-http" }, + "url": { "type": "string", "format": "uri" }, + "root_certificate_ders64": { + "type": "array", + "items": { "type": "string" } + }, + "admin_headers": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + }, + "FileBackend": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "directory" + ], + "properties": { + "type": { "const": "file" }, + "directory": { + "type": "string", + "minLength": 1 + } + } + }, + "HttpBackend": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "server", + "endpoint" + ], + "properties": { + "type": { "const": "http" }, + "server": { + "type": "string", + "minLength": 1 + }, + "endpoint": { + "type": "string", + "pattern": "^/" + } + } + } + } +} diff --git a/src/cli/workspace.toit b/src/cli/workspace.toit new file mode 100644 index 000000000..6c49b6966 --- /dev/null +++ b/src/cli/workspace.toit @@ -0,0 +1,213 @@ +// Copyright (C) 2026 Toitware ApS. All rights reserved. + +import encoding.base64 +import fs +import host.file + +import .server-config +import .utils show read-yaml write-yaml-to-file + +ARTEMIS-FILE ::= "artemis.yaml" +WORKSPACE-SCHEMA ::= "https://toit.io/schemas/artemis/workspace/v1.json" + +FLEET-BACKEND ::= "fleet" +BROKER-BACKEND ::= "broker" +PODS-BACKEND ::= "pods" +ARTIFACTS-BACKEND ::= "artifacts" + +class WorkspaceException: + message/string + + constructor .message: + + stringify -> string: + return message + +workspace-error_ message/string: + throw (WorkspaceException message) + +/** Describes the implementation selected for one Artemis interface. */ +abstract class BackendConfig: + name/string + + constructor.from-sub_ .name: + + /** Parses a backend configuration and resolves its server reference. */ + static from-map name/string encoded/any servers/Map -> BackendConfig: + if encoded is not Map: + workspace-error_ "Backend '$name' must be a map." + + type := encoded.get "type" + if type == "file": + return FileBackendConfig.from-map name encoded + + if type == "http": + return HttpBackendConfig.from-map name encoded servers + + workspace-error_ "Backend '$name' has unknown type '$type'." + unreachable + + /** Serializes this backend configuration. */ + abstract to-map -> Map + +/** Stores an interface in a directory relative to the workspace file. */ +class FileBackendConfig extends BackendConfig: + directory/string + + constructor.from-map name/string encoded/Map: + directory := encoded.get "directory" + if directory is not string or directory.is-empty: + workspace-error_ "File backend '$name' must have a non-empty 'directory'." + return FileBackendConfig name --directory=directory + + constructor name/string --.directory: + super.from-sub_ name + + to-map -> Map: + return { + "type": "file", + "directory": directory, + } + +/** Accesses an interface through a named server and relative endpoint. */ +class HttpBackendConfig extends BackendConfig: + server-config/ServerConfig + endpoint/string + + constructor.from-map name/string encoded/Map servers/Map: + server-name := encoded.get "server" + if server-name is not string or server-name.is-empty: + workspace-error_ "HTTP backend '$name' must reference a server." + if not servers.contains server-name: + workspace-error_ "HTTP backend '$name' references unknown server '$server-name'." + + endpoint := encoded.get "endpoint" + if endpoint is not string or not endpoint.starts-with "/": + workspace-error_ "HTTP backend '$name' must have an absolute-path 'endpoint'." + return HttpBackendConfig name + --server-config=servers[server-name] + --endpoint=endpoint + + constructor name/string --.server-config --.endpoint: + super.from-sub_ name + + server-name -> string: + return server-config.name + + to-map -> Map: + return { + "type": "http", + "server": server-name, + "endpoint": endpoint, + } + +/** +Configuration and local context used by Artemis. + +Named servers own reusable connection and authentication settings. Backend + configurations select independently which server and implementation to use. +*/ +class Workspace: + path/string + servers/Map + backends/Map + + constructor --.path --.servers --.backends: + + /** Loads an `artemis.yaml` from $root-or-path. */ + static load root-or-path/string -> Workspace: + path := root-or-path + if file.is-directory path: path = fs.join path ARTEMIS-FILE + if not file.is-file path: + workspace-error_ "Workspace '$root-or-path' does not contain an $ARTEMIS-FILE file." + + encoded := null + exception := catch: encoded = read-yaml path + if exception: + workspace-error_ "Workspace file '$path' is not valid YAML: $exception" + return from-map encoded --path=path + + /** Parses an encoded workspace configuration. */ + static from-map encoded/any --path/string=ARTEMIS-FILE -> Workspace: + if encoded is not Map: + workspace-error_ "Workspace file '$path' must contain a map." + + schema := encoded.get "\$schema" + if schema != WORKSPACE-SCHEMA: + workspace-error_ "Workspace file '$path' has unsupported schema '$schema'." + + encoded-servers := encoded.get "servers" + if encoded-servers is not Map: + workspace-error_ "Workspace file '$path' must contain a 'servers' map." + servers := encoded-servers.map: | name encoded-server | + if name is not string or name.is-empty: + workspace-error_ "Workspace file '$path' contains an invalid server name." + if encoded-server is not Map: + workspace-error_ "Server '$name' must be a map." + if encoded-server.contains "scope": + workspace-error_ "Server '$name' cannot contain a fleet scope." + if encoded-server.contains "poll_interval" or encoded-server.contains "device_headers": + workspace-error_ "Server '$name' cannot contain embedded device configuration." + ServerConfig.from-json name encoded-server + --der-deserializer=: base64.decode it + + encoded-backends := encoded.get "backends" + if encoded-backends is not Map: + workspace-error_ "Workspace file '$path' must contain a 'backends' map." + backends := encoded-backends.map: | name encoded-backend | + if name is not string or name.is-empty: + workspace-error_ "Workspace file '$path' contains an invalid backend name." + BackendConfig.from-map name encoded-backend servers + + return Workspace --path=path --servers=servers --backends=backends + + /** Returns the configuration for the backend named $name. */ + backend name/string -> BackendConfig: + result := backends.get name + if not result: workspace-error_ "Workspace does not configure a '$name' backend." + return result + + /** Returns the configured fleet backend. */ + fleet -> BackendConfig: + return backend FLEET-BACKEND + + /** Returns the configured broker backend. */ + broker -> BackendConfig: + return backend BROKER-BACKEND + + /** Returns the configured pod backend. */ + pods -> BackendConfig: + return backend PODS-BACKEND + + /** Returns the configured artifact backend. */ + artifacts -> BackendConfig: + return backend ARTIFACTS-BACKEND + + /** Resolves a relative workspace path against the workspace directory. */ + resolve path/string -> string: + if fs.is-absolute path: return path + return fs.join (fs.dirname this.path) path + + /** Serializes this workspace configuration. */ + to-map -> Map: + encoded-servers := {:} + servers.keys.sort.do: | name/string | + server-config/ServerConfig := servers[name] + encoded-servers[name] = server-config.to-workspace-json + --base64 + --der-serializer=: unreachable + + encoded-backends := {:} + backends.keys.sort.do: | name/string | + backend-config/BackendConfig := backends[name] + encoded-backends[name] = backend-config.to-map + + return { + "\$schema": WORKSPACE-SCHEMA, + "servers": encoded-servers, + "backends": encoded-backends, + } + + /** Writes this workspace to its configured $path. */ + write -> none: + write-yaml-to-file path to-map diff --git a/src/shared/server-config.toit b/src/shared/server-config.toit index beff34a71..f2fa0595b 100644 --- a/src/shared/server-config.toit +++ b/src/shared/server-config.toit @@ -63,6 +63,13 @@ abstract class ServerConfig: */ abstract to-json [--der-serializer] --base64/bool=false -> Map + /** + Serializes the reusable connection settings for an Artemis workspace. + + Device behavior and fleet scope are intentionally omitted. + */ + abstract to-workspace-json [--der-serializer] --base64/bool=false -> Map + /** Creates the broker configuration used by the Artemis service. */ @@ -134,11 +141,15 @@ class ServerConfigSupabase extends ServerConfig implements supabase.ServerConfig url = "$scheme://$(json["host"])" scope-value := json.get "scope" scope/Scope? := scope-value and (Scope scope-value) + poll-interval-value := json.get "poll_interval" + poll-interval := poll-interval-value + ? Duration --us=poll-interval-value + : BrokerConfig.DEFAULT-POLL-INTERVAL return ServerConfigSupabase name --url=url --anon=json["anon"] --root-certificate-ders=roots - --poll-interval=Duration --us=json["poll_interval"] + --poll-interval=poll-interval --scope=scope constructor name/string @@ -170,6 +181,17 @@ class ServerConfigSupabase extends ServerConfig implements supabase.ServerConfig if scope: result["scope"] = scope.to-json return result + to-workspace-json [--der-serializer] --base64/bool=false -> Map: + result := { + "type": type, + "url": url, + "anon": anon, + } + add-root-certificates_ result root-certificate-ders + --der-serializer=der-serializer + --base64=base64 + return result + to-broker-config -> BrokerConfig: base-url := without-trailing-slash_ url return BrokerConfig @@ -220,12 +242,16 @@ class ServerConfigHttp extends ServerConfig: url = "$scheme://$(config["host"])$port-suffix$(config["path"])" scope-value := config.get "scope" scope/Scope? := scope-value and (Scope scope-value) + poll-interval-value := config.get "poll_interval" + poll-interval := poll-interval-value + ? Duration --us=poll-interval-value + : BrokerConfig.DEFAULT-POLL-INTERVAL return ServerConfigHttp name --url=url --root-certificate-ders=roots --device-headers=config.get "device_headers" --admin-headers=config.get "admin_headers" - --poll-interval=Duration --us=config["poll_interval"] + --poll-interval=poll-interval --scope=scope constructor name/string @@ -258,6 +284,17 @@ class ServerConfigHttp extends ServerConfig: if scope: result["scope"] = scope.to-json return result + to-workspace-json [--der-serializer] --base64/bool=false -> Map: + result := { + "type": type, + "url": url, + } + add-root-certificates_ result root-certificate-ders + --der-serializer=der-serializer + --base64=base64 + if admin-headers: result["admin_headers"] = admin-headers + return result + to-broker-config -> BrokerConfig: base-url := without-trailing-slash_ url return BrokerConfig diff --git a/tests/package.lock b/tests/package.lock index 7d8900642..8c527d697 100644 --- a/tests/package.lock +++ b/tests/package.lock @@ -56,9 +56,9 @@ packages: url: github.com/toitlang/pkg-fs version: 2.3.1 github.com/toitlang/pkg-host-1: - hash: ae83f761db80166a20bb38498edd009916b72563 + hash: d7a30b86c73cf8d9cd5dd0211031c76b68706738 url: github.com/toitlang/pkg-host - version: 1.16.2 + version: 1.19.0 github.com/toitlang/pkg-http-2: hash: dbe7effdb578273b940b2cd789ecd0ca0a588d85 url: github.com/toitlang/pkg-http diff --git a/tests/package.yaml b/tests/package.yaml index f636f88a9..95c6c4e03 100644 --- a/tests/package.yaml +++ b/tests/package.yaml @@ -17,7 +17,7 @@ dependencies: version: ^2.3.1 host: url: github.com/toitlang/pkg-host - version: ^1.16.2 + version: ^1.19.0 http: url: github.com/toitlang/pkg-http version: ^2.15.0 diff --git a/tests/server-config-test.toit b/tests/server-config-test.toit index aafaff5dc..670663ef4 100644 --- a/tests/server-config-test.toit +++ b/tests/server-config-test.toit @@ -18,9 +18,14 @@ test-supabase-templates: --url="http://localhost:54321/" --anon="anon" cli-json := config.to-json --base64 --der-serializer=: unreachable + expect-equals ["type", "url", "anon", "poll_interval"] cli-json.keys expect-equals "http://localhost:54321" cli-json["url"] expect-not (cli-json.contains "host") expect-not (cli-json.contains "use_tls") + workspace-json := config.to-workspace-json --base64 --der-serializer=: unreachable + expect-equals ["type", "url", "anon"] workspace-json.keys + expect-not (workspace-json.contains "poll_interval") + expect-not (workspace-json.contains "scope") encoded := config.to-service-json --base64 --der-serializer=: unreachable expect-not (encoded.contains "type") expect-equals @@ -50,13 +55,21 @@ test-http-templates: --url="http://localhost:1234/api/" --root-certificate-ders=null --device-headers={"X-Device": "true"} - --admin-headers=null + --admin-headers={"X-Admin": "true"} cli-json := config.to-json --base64 --der-serializer=: unreachable + expect-equals + ["type", "url", "poll_interval", "device_headers", "admin_headers"] + cli-json.keys expect-equals "http://localhost:1234/api" cli-json["url"] expect-not (cli-json.contains "host") expect-not (cli-json.contains "port") expect-not (cli-json.contains "path") expect-not (cli-json.contains "use_tls") + workspace-json := config.to-workspace-json --base64 --der-serializer=: unreachable + expect-equals ["type", "url", "admin_headers"] workspace-json.keys + expect-not (workspace-json.contains "poll_interval") + expect-not (workspace-json.contains "device_headers") + expect-not (workspace-json.contains "scope") encoded := config.to-service-json --base64 --der-serializer=: unreachable expect-equals "http://localhost:1234/api/device/{device-id}/goal" diff --git a/tests/workspace-test.toit b/tests/workspace-test.toit new file mode 100644 index 000000000..7738e6774 --- /dev/null +++ b/tests/workspace-test.toit @@ -0,0 +1,165 @@ +// Copyright (C) 2026 Toitware ApS. All rights reserved. + +import artemis.cli.workspace show + FileBackendConfig + HttpBackendConfig + Workspace + WorkspaceException + WORKSPACE-SCHEMA +import artemis.shared.server-config show ServerConfigSupabase +import expect show * +import fs +import host +import host.file + +main: + test-server-indirection + test-round-trip + test-yaml-file + test-validation + +test-server-indirection: + workspace := Workspace.from-map --path="/work/artemis.yaml" { + "\$schema": WORKSPACE-SCHEMA, + "servers": { + "production": { + "type": "supabase", + "url": "https://example.supabase.co/", + "anon": "anon-key", + }, + }, + "backends": { + "fleet": { + "type": "file", + "directory": "fleet", + }, + "broker": { + "type": "http", + "server": "production", + "endpoint": "/functions/v2/broker", + }, + "pods": { + "type": "http", + "server": "production", + "endpoint": "/functions/v2/pods", + }, + "artifacts": { + "type": "http", + "server": "production", + "endpoint": "/functions/v2/artifacts", + }, + }, + } + + fleet := workspace.fleet as FileBackendConfig + broker := workspace.broker as HttpBackendConfig + pods := workspace.pods as HttpBackendConfig + artifacts := workspace.artifacts as HttpBackendConfig + + expected-fleet-path := fs.join (fs.dirname workspace.path) fleet.directory + expect-equals expected-fleet-path (workspace.resolve fleet.directory) + expect-identical broker.server-config pods.server-config + expect-identical broker.server-config artifacts.server-config + expect broker.server-config is ServerConfigSupabase + expect-equals "https://example.supabase.co" + (broker.server-config as ServerConfigSupabase).url + +test-round-trip: + encoded := { + "\$schema": WORKSPACE-SCHEMA, + "servers": { + "local": { + "type": "toit-http", + "url": "http://localhost:4998", + "admin_headers": {"Authorization": "Bearer token"}, + }, + }, + "backends": { + "broker": { + "type": "http", + "server": "local", + "endpoint": "/broker", + }, + }, + } + + workspace := Workspace.from-map encoded + decoded := Workspace.from-map workspace.to-map + backend := decoded.broker as HttpBackendConfig + expect-equals "local" backend.server-name + expect-equals "/broker" backend.endpoint + server-map := backend.server-config.to-json + --base64 + --der-serializer=: unreachable + expect-equals "Bearer token" server-map["admin_headers"]["Authorization"] + +test-yaml-file: + host.with-tmp-directory: | tmp/string | + path := "$tmp/artemis.yaml" + workspace := Workspace.from-map --path=path { + "\$schema": WORKSPACE-SCHEMA, + "servers": {:}, + "backends": { + "fleet": { + "type": "file", + "directory": "fleet", + }, + }, + } + workspace.write + + expect (file.is-file path) + loaded := Workspace.load tmp + fleet := loaded.fleet as FileBackendConfig + expect-equals (fs.join tmp "fleet") (loaded.resolve fleet.directory) + +test-validation: + expect-workspace-error "Workspace file 'artemis.yaml' has unsupported schema 'null'.": + Workspace.from-map { + "servers": {:}, + "backends": {:}, + } + + expect-workspace-error "HTTP backend 'broker' references unknown server 'missing'.": + Workspace.from-map { + "\$schema": WORKSPACE-SCHEMA, + "servers": {:}, + "backends": { + "broker": { + "type": "http", + "server": "missing", + "endpoint": "/broker", + }, + }, + } + + expect-workspace-error "Server 'production' cannot contain a fleet scope.": + Workspace.from-map { + "\$schema": WORKSPACE-SCHEMA, + "servers": { + "production": { + "type": "toit-http", + "url": "https://example.com", + "scope": "organization", + }, + }, + "backends": {:}, + } + + expect-workspace-error "Server 'production' cannot contain embedded device configuration.": + Workspace.from-map { + "\$schema": WORKSPACE-SCHEMA, + "servers": { + "production": { + "type": "toit-http", + "url": "https://example.com", + "poll_interval": 20_000_000, + }, + }, + "backends": {:}, + } + +expect-workspace-error message/string [block]: + exception := catch: block.call + expect exception is WorkspaceException + expect-equals message (exception as WorkspaceException).message