From 0322545f3f5b9426b54817e7624b41d72374140c Mon Sep 17 00:00:00 2001 From: Carl-Christian Sautter Date: Sun, 17 May 2026 17:17:21 +0000 Subject: [PATCH] feat(extension): add external extension interface --- README.md | 2 + cmd/cmd/extension.go | 108 +++++++ cmd/cmd/extension_test.go | 91 ++++++ docs/extensions.md | 118 ++++++++ pkg/extension/extension.go | 264 +++++++++++++++++ pkg/extension/extension_test.go | 146 ++++++++++ .../dev-alchemy.ansible-bundle.v1.schema.json | 167 +++++++++++ ...-alchemy.extension-manifest.v1.schema.json | 90 ++++++ ...dev-alchemy.system-snapshot.v1.schema.json | 269 ++++++++++++++++++ 9 files changed, 1255 insertions(+) create mode 100644 cmd/cmd/extension.go create mode 100644 cmd/cmd/extension_test.go create mode 100644 docs/extensions.md create mode 100644 pkg/extension/extension.go create mode 100644 pkg/extension/extension_test.go create mode 100644 schemas/dev-alchemy.ansible-bundle.v1.schema.json create mode 100644 schemas/dev-alchemy.extension-manifest.v1.schema.json create mode 100644 schemas/dev-alchemy.system-snapshot.v1.schema.json diff --git a/README.md b/README.md index cf2a75f4..3398335d 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,8 @@ next level of detail: flows - [Managed Application Data](./docs/managed-application-data.md) for cache, runtime, and app-data locations +- [Extensions](./docs/extensions.md) for the external executable interface and + JSON schemas used by analyzers and generators - [Windows Ansible Access](./docs/windows-ansible-access.md) for manual WinRM and SSH setup on Windows targets plus OpenSSH rollback notes - [Example Ansible Roles](./docs/example-roles.md) for the current sample role diff --git a/cmd/cmd/extension.go b/cmd/cmd/extension.go new file mode 100644 index 00000000..d1cdd327 --- /dev/null +++ b/cmd/cmd/extension.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "fmt" + "io" + "text/tabwriter" + + alchemy_extension "github.com/csautter/dev-alchemy/pkg/extension" + "github.com/spf13/cobra" +) + +var ( + extensionDiscoverFunc = alchemy_extension.Discover + extensionRunFunc = alchemy_extension.Run +) + +var extensionCmd = &cobra.Command{ + Use: "extension", + Short: "Discover and run external Dev Alchemy extensions", + Long: `Discover and run external Dev Alchemy extensions. + +Extensions are executable files on PATH named alchemy-. They run as +separate processes so private extensions can integrate through a stable command +and JSON file contract without linking into the open Dev Alchemy binary.`, +} + +var extensionListCmd = &cobra.Command{ + Use: "list", + Short: "List available external extensions", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + extensions, err := extensionDiscoverFunc(alchemy_extension.DiscoverOptions{}) + if err != nil { + return err + } + return printAvailableExtensions(cmd.OutOrStdout(), extensions) + }, +} + +var extensionRunCmd = &cobra.Command{ + Use: "run [-- ]", + Short: "Run an external extension executable", + Long: `Run an external extension executable. + +The extension name resolves to alchemy- on PATH. Pass extension flags +after -- so Dev Alchemy does not parse them.`, + Example: ` alchemy extension run analyzer -- scan --out snapshot.json + alchemy extension run analyzer -- generate --from snapshot.json --out generated-ansible`, + Args: validateExtensionRunArgs, + RunE: func(cmd *cobra.Command, args []string) error { + name, extensionArgs := splitExtensionRunArgs(cmd, args) + return extensionRunFunc(cmd.Context(), alchemy_extension.RunOptions{ + Name: name, + Args: extensionArgs, + Stdin: cmd.InOrStdin(), + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + }) + }, +} + +func printAvailableExtensions(writer io.Writer, extensions []alchemy_extension.Executable) error { + if len(extensions) == 0 { + _, err := fmt.Fprintln(writer, "No Dev Alchemy extensions found on PATH. Install an executable named alchemy- to add one.") + return err + } + + tw := tabwriter.NewWriter(writer, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "NAME\tEXECUTABLE"); err != nil { + return err + } + for _, extension := range extensions { + if _, err := fmt.Fprintf(tw, "%s\t%s\n", extension.Name, extension.Path); err != nil { + return err + } + } + + return tw.Flush() +} + +func validateExtensionRunArgs(cmd *cobra.Command, args []string) error { + positionalArgCount := len(args) + if dashIndex := cmd.ArgsLenAtDash(); dashIndex >= 0 { + positionalArgCount = dashIndex + } + if positionalArgCount < 1 { + return fmt.Errorf("accepts at least 1 arg(s), received %d", positionalArgCount) + } + + return nil +} + +func splitExtensionRunArgs(cmd *cobra.Command, args []string) (string, []string) { + if dashIndex := cmd.ArgsLenAtDash(); dashIndex >= 0 { + return args[0], args[dashIndex:] + } + + if len(args) == 1 { + return args[0], nil + } + return args[0], args[1:] +} + +func init() { + rootCmd.AddCommand(extensionCmd) + extensionCmd.AddCommand(extensionListCmd) + extensionCmd.AddCommand(extensionRunCmd) +} diff --git a/cmd/cmd/extension_test.go b/cmd/cmd/extension_test.go new file mode 100644 index 00000000..0af24b9d --- /dev/null +++ b/cmd/cmd/extension_test.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + + alchemy_extension "github.com/csautter/dev-alchemy/pkg/extension" + "github.com/spf13/cobra" +) + +func TestPrintAvailableExtensions(t *testing.T) { + var output bytes.Buffer + + err := printAvailableExtensions(&output, []alchemy_extension.Executable{ + {Name: "analyzer", Path: "/usr/local/bin/alchemy-analyzer"}, + }) + if err != nil { + t.Fatalf("expected extension listing to succeed, got %v", err) + } + + got := output.String() + for _, want := range []string{"NAME", "EXECUTABLE", "analyzer", "/usr/local/bin/alchemy-analyzer"} { + if !strings.Contains(got, want) { + t.Fatalf("expected output to contain %q, got:\n%s", want, got) + } + } +} + +func TestPrintAvailableExtensionsHandlesEmptyPath(t *testing.T) { + var output bytes.Buffer + + if err := printAvailableExtensions(&output, nil); err != nil { + t.Fatalf("expected empty extension listing to succeed, got %v", err) + } + if !strings.Contains(output.String(), "No Dev Alchemy extensions found") { + t.Fatalf("expected empty extension message, got %q", output.String()) + } +} + +func TestExtensionRunCommandPassesArgumentsAfterDash(t *testing.T) { + previousRunFunc := extensionRunFunc + previousRootOut := rootCmd.OutOrStdout() + previousRootErr := rootCmd.ErrOrStderr() + t.Cleanup(func() { + extensionRunFunc = previousRunFunc + rootCmd.SetArgs(nil) + rootCmd.SetOut(previousRootOut) + rootCmd.SetErr(previousRootErr) + }) + + var capturedOptions alchemy_extension.RunOptions + extensionRunFunc = func(ctx context.Context, options alchemy_extension.RunOptions) error { + capturedOptions = options + return nil + } + + rootCmd.SetArgs([]string{ + "extension", + "run", + "analyzer", + "--", + "scan", + "--out", + "snapshot.json", + }) + rootCmd.SetOut(io.Discard) + rootCmd.SetErr(io.Discard) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("expected extension run to execute successfully, got %v", err) + } + if capturedOptions.Name != "analyzer" { + t.Fatalf("expected analyzer extension, got %q", capturedOptions.Name) + } + if got := strings.Join(capturedOptions.Args, " "); got != "scan --out snapshot.json" { + t.Fatalf("expected args after -- to pass through, got %q", got) + } +} + +func TestExtensionRunCommandPassesPositionalArgumentsWithoutDash(t *testing.T) { + name, args := splitExtensionRunArgs(&cobra.Command{}, []string{"analyzer", "manifest"}) + if name != "analyzer" { + t.Fatalf("expected analyzer extension, got %q", name) + } + if got := strings.Join(args, " "); got != "manifest" { + t.Fatalf("expected positional extension args to pass through, got %q", got) + } +} diff --git a/docs/extensions.md b/docs/extensions.md new file mode 100644 index 00000000..8009436a --- /dev/null +++ b/docs/extensions.md @@ -0,0 +1,118 @@ +# Dev Alchemy Extensions + +Dev Alchemy extensions are external executables that integrate through files, +JSON documents, stdin/stdout, and process boundaries. They are not Go plugins +and they are not linked into the `alchemy` binary. + +This keeps the open Dev Alchemy core useful on its own while allowing separate +open or proprietary tools to provide extra capabilities such as system analysis, +inventory import, policy checks, or generated Ansible content. + +## Command Surface + +Install an extension as an executable on `PATH` named: + +```text +alchemy- +``` + +Examples: + +```text +alchemy-analyzer +alchemy-inventory-import +alchemy-policy-check +``` + +List installed extensions: + +```bash +alchemy extension list +``` + +Run an extension: + +```bash +alchemy extension run analyzer -- scan --out snapshot.json +alchemy extension run analyzer -- generate --from snapshot.json --out generated-ansible +``` + +Arguments after `--` are passed to the extension unchanged. The open CLI does +not interpret extension-specific flags. + +## Execution Contract + +When Dev Alchemy runs an extension, it: + +- resolves `alchemy-` from `PATH` +- starts it as a separate process without shell parsing +- connects stdin, stdout, and stderr to the current CLI process +- sets `DEV_ALCHEMY_EXTENSION_PROTOCOL=1` +- sets `DEV_ALCHEMY_EXTENSION_NAME=` + +Extension names must use letters, digits, `.`, `_`, or `-`, and must not contain +path separators. + +## Recommended Extension Commands + +Extensions can expose any command surface they need. For system-analysis +extensions, use these command names unless there is a strong reason to differ: + +```bash +alchemy extension run analyzer -- manifest +alchemy extension run analyzer -- scan --out snapshot.json +alchemy extension run analyzer -- generate --from snapshot.json --out generated-ansible +alchemy extension run analyzer -- validate --bundle generated-ansible +``` + +Recommended meanings: + +- `manifest` writes extension metadata matching + [`dev-alchemy.extension-manifest.v1.schema.json`](../schemas/dev-alchemy.extension-manifest.v1.schema.json) +- `scan` writes a system snapshot matching + [`dev-alchemy.system-snapshot.v1.schema.json`](../schemas/dev-alchemy.system-snapshot.v1.schema.json) +- `generate` writes an Ansible bundle and metadata matching + [`dev-alchemy.ansible-bundle.v1.schema.json`](../schemas/dev-alchemy.ansible-bundle.v1.schema.json) +- `validate` checks an existing generated bundle before provisioning + +## System Analyzer Flow + +A closed or open analyzer should use Dev Alchemy as the provisioning and test +surface, not as a linked library: + +```bash +alchemy extension run analyzer -- scan --out snapshot.json +alchemy extension run analyzer -- generate --from snapshot.json --out generated-ansible +alchemy provision local --playbook generated-ansible/playbooks/site.yml --check +``` + +Generated bundles should prefer normal Ansible layout: + +```text +generated-ansible/ + metadata.json + inventory/ + playbooks/ + roles/ + group_vars/ + host_vars/ +``` + +The generated `metadata.json` should describe the bundle with the Ansible bundle +schema. Dev Alchemy can then run the generated playbooks with the normal +`alchemy provision` wrapper. + +## Versioning + +The current process protocol is `DEV_ALCHEMY_EXTENSION_PROTOCOL=1`. + +JSON documents should include one of these schema version values: + +```text +dev-alchemy.extension-manifest.v1 +dev-alchemy.system-snapshot.v1 +dev-alchemy.ansible-bundle.v1 +``` + +Future incompatible changes should add new schema versions instead of changing +the meaning of existing fields. diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go new file mode 100644 index 00000000..f1aad6a0 --- /dev/null +++ b/pkg/extension/extension.go @@ -0,0 +1,264 @@ +package extension + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" +) + +const ( + ExecutablePrefix = "alchemy-" + ProtocolVersion = "1" + + ProtocolEnvVar = "DEV_ALCHEMY_EXTENSION_PROTOCOL" + NameEnvVar = "DEV_ALCHEMY_EXTENSION_NAME" +) + +type Executable struct { + Name string + Path string +} + +type DiscoverOptions struct { + PathEnv string +} + +type RunOptions struct { + Name string + Args []string + PathEnv string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + ExtraEnv []string +} + +func Discover(options DiscoverOptions) ([]Executable, error) { + pathEnv := options.PathEnv + if pathEnv == "" { + pathEnv = os.Getenv("PATH") + } + if strings.TrimSpace(pathEnv) == "" { + return nil, nil + } + + seen := make(map[string]Executable) + for _, dir := range filepath.SplitList(pathEnv) { + if strings.TrimSpace(dir) == "" { + continue + } + + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name, ok := NameFromExecutable(entry.Name()) + if !ok { + continue + } + if err := ValidateName(name); err != nil { + continue + } + if _, exists := seen[name]; exists { + continue + } + + path := filepath.Join(dir, entry.Name()) + executable, err := isExecutableFile(path) + if err != nil || !executable { + continue + } + + seen[name] = Executable{Name: name, Path: path} + } + } + + extensions := make([]Executable, 0, len(seen)) + for _, extension := range seen { + extensions = append(extensions, extension) + } + sort.Slice(extensions, func(i, j int) bool { + return extensions[i].Name < extensions[j].Name + }) + + return extensions, nil +} + +func Resolve(name string, options DiscoverOptions) (Executable, error) { + normalizedName := NormalizeName(name) + if err := ValidateName(normalizedName); err != nil { + return Executable{}, err + } + + extensions, err := Discover(options) + if err != nil { + return Executable{}, err + } + for _, extension := range extensions { + if extension.Name == normalizedName { + return extension, nil + } + } + + return Executable{}, fmt.Errorf("extension %q not found on PATH; expected an executable named %q", normalizedName, ExecutablePrefix+normalizedName) +} + +func Run(ctx context.Context, options RunOptions) error { + extension, err := Resolve(options.Name, DiscoverOptions{PathEnv: options.PathEnv}) + if err != nil { + return err + } + + stdin := options.Stdin + if stdin == nil { + stdin = os.Stdin + } + stdout := options.Stdout + if stdout == nil { + stdout = os.Stdout + } + stderr := options.Stderr + if stderr == nil { + stderr = os.Stderr + } + + // #nosec G204 -- extension resolution restricts execution to alchemy-* executables found on PATH; arguments are passed without shell parsing. + cmd := exec.CommandContext(ctx, extension.Path, options.Args...) + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + cmd.Env = extensionEnvironment(os.Environ(), extension.Name, options.PathEnv, options.ExtraEnv) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("run extension %q (%s): %w", extension.Name, extension.Path, err) + } + + return nil +} + +func NormalizeName(name string) string { + normalized := strings.TrimSpace(name) + if strings.HasPrefix(normalized, ExecutablePrefix) { + normalized = strings.TrimPrefix(normalized, ExecutablePrefix) + } + return normalized +} + +func ValidateName(name string) error { + if name == "" { + return errors.New("extension name cannot be empty") + } + if strings.ContainsAny(name, `/\`) { + return fmt.Errorf("extension name cannot contain path separators: %q", name) + } + + for index, value := range name { + valid := isASCIILetter(value) || + isASCIIDigit(value) || + value == '-' || + value == '_' || + value == '.' + if !valid { + return fmt.Errorf("extension name contains unsupported character %q: %q", value, name) + } + if index == 0 && (value == '-' || value == '.') { + return fmt.Errorf("extension name must start with a letter, digit, or underscore: %q", name) + } + } + + return nil +} + +func isASCIILetter(value rune) bool { + return (value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') +} + +func isASCIIDigit(value rune) bool { + return value >= '0' && value <= '9' +} + +func NameFromExecutable(filename string) (string, bool) { + if !strings.HasPrefix(filename, ExecutablePrefix) { + return "", false + } + + name := strings.TrimPrefix(filename, ExecutablePrefix) + name = trimExecutableSuffix(name) + if name == "" { + return "", false + } + + return name, true +} + +func trimExecutableSuffix(name string) string { + switch strings.ToLower(filepath.Ext(name)) { + case ".exe", ".cmd", ".bat", ".com", ".ps1": + return strings.TrimSuffix(name, filepath.Ext(name)) + default: + return name + } +} + +func isExecutableFile(path string) (bool, error) { + info, err := os.Stat(path) + if err != nil { + return false, err + } + if info.IsDir() { + return false, nil + } + if runtime.GOOS == "windows" { + switch strings.ToLower(filepath.Ext(path)) { + case ".exe", ".cmd", ".bat", ".com", ".ps1": + return true, nil + default: + return false, nil + } + } + + return info.Mode().Perm()&0o111 != 0, nil +} + +func extensionEnvironment(baseEnv []string, name string, pathEnv string, extraEnv []string) []string { + env := append([]string{}, baseEnv...) + env = append(env, extraEnv...) + if pathEnv != "" { + env = upsertEnv(env, "PATH", pathEnv) + } + env = upsertEnv(env, ProtocolEnvVar, ProtocolVersion) + env = upsertEnv(env, NameEnvVar, name) + return env +} + +func upsertEnv(env []string, key string, value string) []string { + prefix := key + "=" + next := make([]string, 0, len(env)+1) + replaced := false + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + if !replaced { + next = append(next, prefix+value) + replaced = true + } + continue + } + next = append(next, entry) + } + if !replaced { + next = append(next, prefix+value) + } + return next +} diff --git a/pkg/extension/extension_test.go b/pkg/extension/extension_test.go new file mode 100644 index 00000000..b6e0f0c6 --- /dev/null +++ b/pkg/extension/extension_test.go @@ -0,0 +1,146 @@ +package extension + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestDiscoverFindsExecutableExtensionsOnPath(t *testing.T) { + dir := t.TempDir() + createTestExecutable(t, filepath.Join(dir, "alchemy-analyzer")) + createTestExecutable(t, filepath.Join(dir, "alchemy-generate")) + createTestFile(t, filepath.Join(dir, "alchemy-not-executable"), 0o600) + createTestExecutable(t, filepath.Join(dir, "other-tool")) + + extensions, err := Discover(DiscoverOptions{PathEnv: dir}) + if err != nil { + t.Fatalf("expected discovery to succeed, got %v", err) + } + + if len(extensions) != 2 { + t.Fatalf("expected 2 extensions, got %d: %v", len(extensions), extensions) + } + if extensions[0].Name != "analyzer" { + t.Fatalf("expected analyzer extension first, got %q", extensions[0].Name) + } + if extensions[1].Name != "generate" { + t.Fatalf("expected generate extension second, got %q", extensions[1].Name) + } +} + +func TestDiscoverKeepsFirstPathMatch(t *testing.T) { + firstDir := t.TempDir() + secondDir := t.TempDir() + firstPath := createTestExecutable(t, filepath.Join(firstDir, "alchemy-analyzer")) + createTestExecutable(t, filepath.Join(secondDir, "alchemy-analyzer")) + + extensions, err := Discover(DiscoverOptions{PathEnv: strings.Join([]string{firstDir, secondDir}, string(os.PathListSeparator))}) + if err != nil { + t.Fatalf("expected discovery to succeed, got %v", err) + } + if len(extensions) != 1 { + t.Fatalf("expected 1 extension, got %d: %v", len(extensions), extensions) + } + if extensions[0].Path != firstPath { + t.Fatalf("expected first PATH match %q, got %q", firstPath, extensions[0].Path) + } +} + +func TestResolveAcceptsNameWithExecutablePrefix(t *testing.T) { + dir := t.TempDir() + executablePath := createTestExecutable(t, filepath.Join(dir, "alchemy-analyzer")) + + resolved, err := Resolve("alchemy-analyzer", DiscoverOptions{PathEnv: dir}) + if err != nil { + t.Fatalf("expected prefixed extension name to resolve, got %v", err) + } + if resolved.Name != "analyzer" { + t.Fatalf("expected normalized extension name, got %q", resolved.Name) + } + if resolved.Path != executablePath { + t.Fatalf("expected path %q, got %q", executablePath, resolved.Path) + } +} + +func TestResolveRejectsPathLikeNames(t *testing.T) { + _, err := Resolve("../analyzer", DiscoverOptions{PathEnv: t.TempDir()}) + if err == nil { + t.Fatal("expected path-like extension name to fail") + } + if !strings.Contains(err.Error(), "path separators") { + t.Fatalf("expected path separator error, got %v", err) + } +} + +func TestNameFromExecutableTrimsKnownExecutableSuffixes(t *testing.T) { + name, ok := NameFromExecutable("alchemy-analyzer.exe") + if !ok { + t.Fatal("expected executable name to be recognized") + } + if name != "analyzer" { + t.Fatalf("expected analyzer name, got %q", name) + } +} + +func TestExtensionEnvironmentSetsProtocolValues(t *testing.T) { + env := extensionEnvironment( + []string{"PATH=/bin", ProtocolEnvVar + "=old"}, + "analyzer", + "/custom/bin", + []string{ProtocolEnvVar + "=older", NameEnvVar + "=old"}, + ) + + assertEnvValue(t, env, "PATH", "/custom/bin") + assertEnvValue(t, env, ProtocolEnvVar, ProtocolVersion) + assertEnvValue(t, env, NameEnvVar, "analyzer") + assertSingleEnvValue(t, env, ProtocolEnvVar) +} + +func createTestExecutable(t *testing.T, path string) string { + t.Helper() + mode := os.FileMode(0o755) + if runtime.GOOS == "windows" { + path = path + ".cmd" + mode = 0o600 + } + createTestFile(t, path, mode) + return path +} + +func createTestFile(t *testing.T, path string, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), mode); err != nil { + t.Fatalf("failed to create test file %q: %v", path, err) + } +} + +func assertEnvValue(t *testing.T, env []string, key string, want string) { + t.Helper() + prefix := key + "=" + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + if got := strings.TrimPrefix(entry, prefix); got != want { + t.Fatalf("expected %s=%q, got %q", key, want, got) + } + return + } + } + t.Fatalf("expected env to include %s", key) +} + +func assertSingleEnvValue(t *testing.T, env []string, key string) { + t.Helper() + prefix := key + "=" + count := 0 + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + count++ + } + } + if count != 1 { + t.Fatalf("expected exactly one %s env entry, got %d in %v", key, count, env) + } +} diff --git a/schemas/dev-alchemy.ansible-bundle.v1.schema.json b/schemas/dev-alchemy.ansible-bundle.v1.schema.json new file mode 100644 index 00000000..2934962c --- /dev/null +++ b/schemas/dev-alchemy.ansible-bundle.v1.schema.json @@ -0,0 +1,167 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/csautter/dev-alchemy/schemas/dev-alchemy.ansible-bundle.v1.schema.json", + "title": "Dev Alchemy Ansible Bundle v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "generated_at", + "generator", + "playbooks" + ], + "properties": { + "schema_version": { + "const": "dev-alchemy.ansible-bundle.v1" + }, + "generated_at": { + "type": "string", + "format": "date-time" + }, + "generator": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "version" + ], + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "source_snapshot": { + "type": "string" + }, + "root": { + "type": "string", + "description": "Bundle root directory, relative to metadata.json when omitted." + }, + "playbooks": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + }, + "entrypoint": { + "type": "boolean" + }, + "purpose": { + "type": "string" + } + } + } + }, + "roles": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "path" + ], + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "generated", + "referenced", + "vendored", + "unknown" + ] + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "inventory": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "hosts": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "vars": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "group", + "host", + "playbook", + "role", + "unknown" + ] + } + } + } + }, + "files": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + }, + "purpose": { + "type": "string" + } + } + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } +} diff --git a/schemas/dev-alchemy.extension-manifest.v1.schema.json b/schemas/dev-alchemy.extension-manifest.v1.schema.json new file mode 100644 index 00000000..d8454aad --- /dev/null +++ b/schemas/dev-alchemy.extension-manifest.v1.schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/csautter/dev-alchemy/schemas/dev-alchemy.extension-manifest.v1.schema.json", + "title": "Dev Alchemy Extension Manifest v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "name", + "version", + "capabilities" + ], + "properties": { + "schema_version": { + "const": "dev-alchemy.extension-manifest.v1" + }, + "name": { + "type": "string", + "pattern": "^[A-Za-z0-9_][A-Za-z0-9._-]*$" + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "homepage": { + "type": "string", + "format": "uri" + }, + "license": { + "type": "string" + }, + "capabilities": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$" + } + }, + "commands": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "description" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$" + }, + "usage": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + }, + "consumes": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "produces": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } +} diff --git a/schemas/dev-alchemy.system-snapshot.v1.schema.json b/schemas/dev-alchemy.system-snapshot.v1.schema.json new file mode 100644 index 00000000..7953131b --- /dev/null +++ b/schemas/dev-alchemy.system-snapshot.v1.schema.json @@ -0,0 +1,269 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/csautter/dev-alchemy/schemas/dev-alchemy.system-snapshot.v1.schema.json", + "title": "Dev Alchemy System Snapshot v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "captured_at", + "source" + ], + "properties": { + "schema_version": { + "const": "dev-alchemy.system-snapshot.v1" + }, + "captured_at": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "scanner", + "os" + ], + "properties": { + "host": { + "type": "string" + }, + "scanner": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "version" + ], + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "os": { + "type": "object", + "additionalProperties": false, + "required": [ + "family", + "name", + "arch" + ], + "properties": { + "family": { + "type": "string", + "enum": [ + "darwin", + "linux", + "windows", + "unknown" + ] + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "arch": { + "type": "string" + } + } + } + } + }, + "facts": { + "type": "object", + "additionalProperties": true + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "manager": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "present", + "absent", + "unknown" + ] + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "services": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "running", + "stopped", + "disabled", + "unknown" + ] + }, + "enabled": { + "type": "boolean" + }, + "manager": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "users": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "uid": { + "type": [ + "integer", + "string" + ] + }, + "groups": { + "type": "array", + "items": { + "type": "string" + } + }, + "home": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "files": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "file", + "directory", + "symlink", + "unknown" + ] + }, + "state": { + "type": "string", + "enum": [ + "present", + "absent", + "unknown" + ] + }, + "mode": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "group": { + "type": "string" + }, + "checksum": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "observations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "category", + "id", + "value" + ], + "properties": { + "category": { + "type": "string" + }, + "id": { + "type": "string" + }, + "value": true, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + } +}