diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml
index 38c5993..5e3b412 100644
--- a/.github/workflows/nuget-publish.yml
+++ b/.github/workflows/nuget-publish.yml
@@ -17,14 +17,16 @@ jobs:
security-events: read # critical-vuln gate job
uses: simplify9/.github/.github/workflows/reusable-nuget-publish.yml@0f33f79176152bc11acf0ff08ed970ae8b429b3d # main
with:
- # Contract FIRST: both of the others depend on it, and publishing them without it
- # produces packages whose dependency cannot resolve — which is what 8.1.13 shipped as.
+ # Contract FIRST: the others depend on it, and publishing them without it produces
+ # packages whose dependency cannot resolve — which is what 8.1.13 shipped as. Tooling
+ # last: it depends on the host and the contract.
nuget-projects: >-
SW.Serverless.Contract/SW.Serverless.Contract.csproj
SW.Serverless/SW.Serverless.csproj
SW.Serverless.Sdk/SW.Serverless.Sdk.csproj
+ SW.Serverless.Tooling/SW.Serverless.Tooling.csproj
major-version: '10'
- minor-version: '0'
+ minor-version: '1'
dotnet-version: '10.0.x'
run-tests: true
test-projects: '**/*UnitTests/*.csproj'
diff --git a/.gitignore b/.gitignore
index 1139e7b..3e29084 100644
--- a/.gitignore
+++ b/.gitignore
@@ -337,3 +337,4 @@ ASALocalRun/
binaries
out
+.test-work/
diff --git a/SW.Serverless.Compat.ManifestV1002/Program.cs b/SW.Serverless.Compat.ManifestV1002/Program.cs
new file mode 100644
index 0000000..be6b3cd
--- /dev/null
+++ b/SW.Serverless.Compat.ManifestV1002/Program.cs
@@ -0,0 +1,12 @@
+using System;
+using System.IO;
+using SW.Serverless.Contract.Catalog;
+
+// Reads the manifest at args[0] with the 10.0.2 parser and writes it back: what a released tool
+// does to a manifest a newer one wrote. Prints the problems 10.0.2 finds, then the JSON.
+var manifest = AdapterManifest.Parse(File.ReadAllText(args[0]));
+foreach (var problem in manifest.Validate())
+ Console.WriteLine("PROBLEM:" + problem);
+Console.WriteLine("ENTRY:" + manifest.Entry);
+Console.WriteLine("RUNTIME:" + manifest.Runtime);
+Console.WriteLine("JSON:" + manifest.ToJson().ReplaceLineEndings(" "));
diff --git a/SW.Serverless.Compat.ManifestV1002/SW.Serverless.Compat.ManifestV1002.csproj b/SW.Serverless.Compat.ManifestV1002/SW.Serverless.Compat.ManifestV1002.csproj
new file mode 100644
index 0000000..f498991
--- /dev/null
+++ b/SW.Serverless.Compat.ManifestV1002/SW.Serverless.Compat.ManifestV1002.csproj
@@ -0,0 +1,16 @@
+
+
+
+
+ Exe
+ net10.0
+ false
+
+
+
+
+
+
+
diff --git a/SW.Serverless.CompatibilityTests/Compat.cs b/SW.Serverless.CompatibilityTests/Compat.cs
index 01a2cc7..f6c9640 100644
--- a/SW.Serverless.CompatibilityTests/Compat.cs
+++ b/SW.Serverless.CompatibilityTests/Compat.cs
@@ -8,7 +8,7 @@
using SW.CloudFiles.Extensions;
using SW.PrimitiveTypes;
using SW.Serverless.Installer;
-using SW.Serverless.Installer.Shared;
+using SW.Serverless.Tooling;
namespace SW.Serverless.CompatibilityTests;
@@ -146,6 +146,28 @@ public record OldHostRun(int ExitCode, List Lines)
}
/// Runs the host built on the PUBLISHED SimplyWorks.Serverless 10.0.0 as its own process.
+ /// Reads a manifest file with the parser published in SimplyWorks.Serverless.Contract 10.0.2.
+ public static async Task ReadWithManifestV1002Async(string manifestPath)
+ {
+ var entry = System.IO.Path.Combine(BuildOutput("SW.Serverless.Compat.ManifestV1002"), "SW.Serverless.Compat.ManifestV1002.dll");
+ var start = new ProcessStartInfo("dotnet") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
+ start.ArgumentList.Add(entry);
+ start.ArgumentList.Add(manifestPath);
+
+ using var process = Process.Start(start)!;
+ var stdout = process.StandardOutput.ReadToEndAsync();
+ var stderr = process.StandardError.ReadToEndAsync();
+ if (!process.WaitForExit(60_000))
+ {
+ process.Kill(true);
+ Assert.Fail("the 10.0.2 manifest reader did not finish within a minute");
+ }
+
+ var lines = (await stdout).Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.TrimEnd('\r')).ToList();
+ lines.AddRange((await stderr).Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => "stderr: " + l.TrimEnd('\r')));
+ return new OldHostRun(process.ExitCode, lines);
+ }
+
public static async Task RunOldHostAsync(Bucket bucket, string adapter, params string[] args)
{
var entry = System.IO.Path.Combine(BuildOutput("SW.Serverless.Compat.OldHost"), "SW.Serverless.Compat.OldHost.dll");
diff --git a/SW.Serverless.CompatibilityTests/ListingAndManifestTests.cs b/SW.Serverless.CompatibilityTests/ListingAndManifestTests.cs
index bf5aa0c..edd35ae 100644
--- a/SW.Serverless.CompatibilityTests/ListingAndManifestTests.cs
+++ b/SW.Serverless.CompatibilityTests/ListingAndManifestTests.cs
@@ -3,7 +3,7 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using SW.PrimitiveTypes;
using SW.Serverless.Contract.Catalog;
-using SW.Serverless.Installer.Shared;
+using SW.Serverless.Tooling;
namespace SW.Serverless.CompatibilityTests;
diff --git a/SW.Serverless.CompatibilityTests/MultiLanguageManifestTests.cs b/SW.Serverless.CompatibilityTests/MultiLanguageManifestTests.cs
new file mode 100644
index 0000000..7dee375
--- /dev/null
+++ b/SW.Serverless.CompatibilityTests/MultiLanguageManifestTests.cs
@@ -0,0 +1,106 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using SW.Serverless.Contract.Catalog;
+
+namespace SW.Serverless.CompatibilityTests;
+
+///
+/// The manifest fields multi-language adapters add — runtime version, platforms and their entries,
+/// contracts, the source the package carries — written by the current tools and read by the
+/// manifest parser released Bitween uses (SimplyWorks.Serverless.Contract 10.0.2). That parser
+/// must keep every one of them, find nothing wrong, and still see the fields it knows as before.
+///
+[TestClass]
+public class MultiLanguageManifestTests
+{
+ static AdapterManifest Written() => new()
+ {
+ Id = "acme.orders",
+ Version = "2.1.0",
+ DisplayName = "Acme orders",
+ Kinds = { "handler" },
+ Runtime = AdapterManifest.PythonRuntime,
+ RuntimeVersion = ">=3.12",
+ Language = "python",
+ Entry = "main.py",
+ Lifecycle = AdapterManifest.ClassicLifecycle,
+ Platforms = new() { "linux-x64", "linux-arm64" },
+ Entries = new() { ["linux-x64"] = "x64/main.py", ["linux-arm64"] = "arm64/main.py" },
+ Contracts = new() { ["bitween"] = 1 },
+ Source = new AdapterSource
+ {
+ Files = new() { ["main.py"] = new string('a', 64), ["requirements.lock"] = new string('b', 64) },
+ BuildCommand = "serverless build",
+ Lockfiles = new() { "requirements.lock" },
+ },
+ };
+
+ [TestMethod]
+ public async Task The_released_manifest_parser_keeps_every_new_field_and_finds_nothing_wrong()
+ {
+ var written = Written();
+ Assert.AreEqual(0, written.Validate().Count, string.Join("; ", written.Validate()));
+ var path = Path.Combine(Path.GetTempPath(), $"manifest-{Guid.NewGuid():N}.json");
+ await File.WriteAllTextAsync(path, written.ToJson());
+ try
+ {
+ var read = await Compat.ReadWithManifestV1002Async(path);
+
+ Assert.AreEqual(0, read.ExitCode, read.ToString());
+ Assert.IsNull(read.Value("PROBLEM"), read.ToString());
+ Assert.AreEqual("main.py", read.Value("ENTRY"), "the default entry is what released readers use");
+ Assert.AreEqual("python", read.Value("RUNTIME"), "a released host sees the runtime, and refuses one it can't run");
+
+ var before = JsonNode.Parse(written.ToJson())!;
+ var after = JsonNode.Parse(read.Value("JSON")!)!;
+ foreach (var field in new[] { "runtimeVersion", "platforms", "entries", "contracts", "source" })
+ Assert.IsTrue(JsonNode.DeepEquals(before[field], after[field]), $"{field} changed in a round trip through 10.0.2:\n{after[field]}");
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [TestMethod]
+ public void The_new_fields_round_trip_through_the_current_parser()
+ {
+ var again = AdapterManifest.Parse(Written().ToJson());
+
+ Assert.AreEqual(">=3.12", again.RuntimeVersion);
+ CollectionAssert.AreEqual(new[] { "linux-x64", "linux-arm64" }, again.Platforms);
+ Assert.AreEqual("arm64/main.py", again.EntryFor("linux-arm64"));
+ Assert.AreEqual("main.py", again.EntryFor("osx-arm64"), "a platform without its own entry uses the default");
+ Assert.AreEqual(1, again.Contracts!["bitween"]);
+ Assert.AreEqual(AdapterSource.DefaultPath, again.Source!.Path);
+ Assert.AreEqual(2, again.Source.Files.Count);
+ Assert.IsNull(again.Extensions, "nothing unknown to the current parser");
+ }
+
+ [TestMethod]
+ public void A_manifest_without_the_new_fields_is_read_as_a_dotnet_adapter_as_before()
+ {
+ var old = AdapterManifest.Parse("""{ "id": "legacy", "entry": "Legacy.dll" }""");
+
+ Assert.AreEqual(AdapterManifest.DotnetRuntime, old.Runtime);
+ Assert.AreEqual("Legacy.dll", old.EntryFor("linux-x64"));
+ Assert.IsNull(old.Platforms);
+ Assert.IsNull(old.Source);
+ Assert.AreEqual(0, old.Validate().Count);
+ }
+
+ [DataTestMethod]
+ [DataRow("""{ "runtime": "../bin/sh" }""", "runtime")]
+ [DataRow("""{ "platforms": ["Linux x64"] }""", "platform")]
+ [DataRow("""{ "platforms": ["linux-x64"], "entries": { "osx-arm64": "a" } }""", "doesn't list")]
+ [DataRow("""{ "platforms": ["linux-x64"], "entries": { "linux-x64": "../../etc/passwd" } }""", "inside the package")]
+ [DataRow("""{ "contracts": { "bitween": 0 } }""", "contract")]
+ [DataRow("""{ "source": { "files": { "main.py": "not-a-hash" } } }""", "SHA-256")]
+ [DataRow("""{ "source": { "path": "/abs", "files": {} } }""", "source.path")]
+ public void A_wrong_new_field_is_named_by_validation(string json, string expected)
+ {
+ var problems = AdapterManifest.Parse(json).Validate();
+ Assert.IsTrue(problems.Any(p => p.Contains(expected)), $"expected a problem about {expected}: {string.Join("; ", problems)}");
+ }
+}
diff --git a/SW.Serverless.CompatibilityTests/NewHostTests.cs b/SW.Serverless.CompatibilityTests/NewHostTests.cs
index f1ce3fe..4ac3108 100644
--- a/SW.Serverless.CompatibilityTests/NewHostTests.cs
+++ b/SW.Serverless.CompatibilityTests/NewHostTests.cs
@@ -141,6 +141,14 @@ public async Task A_resident_adapter_on_sdk_10_0_0_runs_on_the_current_host()
Assert.AreEqual(InstanceState.Ready, instance.State);
Assert.AreEqual("v10:ping", await instance.InvokeAsync("Echo", "ping"));
+ // The 10.0.0 SDK knows nothing of the language-neutral Hello fields: they're simply
+ // empty, and nothing depends on them being there.
+ var described = adapters.Describe().Single(h => h.InstanceKey == "compat");
+ Assert.AreEqual("", described.SdkLanguage);
+ Assert.AreEqual(0, described.Settings.Count);
+ Assert.AreEqual(0, described.Kinds.Count);
+ Assert.AreEqual(0, described.Contracts.Count);
+
await adapters.StopAsync("sdk10.resident", "compat", drain: false);
}
finally
diff --git a/SW.Serverless.CompatibilityTests/PackageLayoutTests.cs b/SW.Serverless.CompatibilityTests/PackageLayoutTests.cs
new file mode 100644
index 0000000..e3d5815
--- /dev/null
+++ b/SW.Serverless.CompatibilityTests/PackageLayoutTests.cs
@@ -0,0 +1,160 @@
+using System.IO.Compression;
+using System.Security.Cryptography;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using SW.PrimitiveTypes;
+using SW.Serverless.Contract.Catalog;
+using SW.Serverless.Tooling;
+
+namespace SW.Serverless.CompatibilityTests;
+
+///
+/// The package layout multi-language adapters bring, against the hosts already deployed:
+/// a package that carries its source runs on old and new hosts alike, and only new hosts leave
+/// the source unpacked; an adapter in another runtime is kept out of old hosts' sight entirely —
+/// they only ever run adapters/{id}, and always with dotnet — while new hosts find it in the catalog.
+///
+[TestClass]
+public class PackageLayoutTests
+{
+ const string Sample = "SW.Serverless.Samples.Classic";
+
+ /// The sample's build output as a zip, with an adapter.json and any extra entries.
+ static string Package(AdapterManifest manifest, IDictionary? extra = null)
+ {
+ var output = Compat.BuildOutput(Sample);
+ var zip = Path.Combine(Path.GetTempPath(), "swsl-compat", $"layout-{Guid.NewGuid():N}.zip");
+ Directory.CreateDirectory(Path.GetDirectoryName(zip)!);
+ using (var archive = ZipFile.Open(zip, ZipArchiveMode.Create))
+ {
+ foreach (var file in Directory.EnumerateFiles(output, "*", SearchOption.AllDirectories))
+ archive.CreateEntryFromFile(file, Path.GetRelativePath(output, file).Replace('\\', '/'), CompressionLevel.Fastest);
+ using (var writer = new StreamWriter(archive.CreateEntry(AdapterManifest.FileName).Open()))
+ writer.Write(manifest.ToJson());
+ foreach (var (name, content) in extra ?? new Dictionary())
+ using (var writer = new StreamWriter(archive.CreateEntry(name).Open()))
+ writer.Write(content);
+ }
+ return zip;
+ }
+
+ static string Sha256(string text) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))).ToLowerInvariant();
+
+ static AdapterManifest DotnetWithSource(string id) => new()
+ {
+ Id = id,
+ Entry = Sample + ".dll",
+ Source = new AdapterSource { Files = { ["Program.cs"] = Sha256("// the source"), ["Sample.csproj"] = Sha256("") } },
+ };
+
+ static AdapterManifest Python(string id) => new()
+ {
+ Id = id,
+ Runtime = AdapterManifest.PythonRuntime,
+ Language = "python",
+ Entry = "main.py",
+ Kinds = { "handler" },
+ };
+
+ static AdapterRepository Repository(Bucket bucket) => new(bucket.Files, _ => { });
+
+ [TestMethod]
+ public async Task A_package_carrying_its_source_runs_on_old_and_new_hosts_and_new_ones_leave_the_source_packed()
+ {
+ using var bucket = new Bucket();
+ const string id = "layout.withsource";
+ var zip = Package(DotnetWithSource(id), new Dictionary
+ {
+ ["source/Program.cs"] = "// the source",
+ ["source/Sample.csproj"] = "",
+ });
+ await Repository(bucket).PublishVersionAsync(id, "1.0.0", zip,
+ new PackageInfo { EntryAssembly = Sample + ".dll", Manifest = DotnetWithSource(id) }, promote: true, "compat");
+
+ var old = await Compat.RunOldHostAsync(bucket, id, "--command", "Echo", "--input", "carried");
+ Assert.AreEqual(0, old.ExitCode, old.ToString());
+ Assert.AreEqual("carried", old.Value("RESULT"), "a host on the released 10.0.0 runs it as before");
+
+ using var host = Compat.NewHost(bucket);
+ using (var service = (ServerlessService)host.GetRequiredService())
+ {
+ await service.StartAsync(id, "correlation");
+ Assert.AreEqual("carried", await service.InvokeAsync("Echo", "carried"));
+ }
+
+ var installed = await host.GetRequiredService().InstallAsync(id);
+ Assert.IsTrue(File.Exists(installed.LocalPath));
+ Assert.IsFalse(Directory.Exists(Path.Combine(installed.Directory, "source")), "the source was unpacked onto the host");
+ }
+
+ [TestMethod]
+ public async Task A_folder_called_source_in_a_package_without_declared_source_is_unpacked_as_before()
+ {
+ using var bucket = new Bucket();
+ const string id = "layout.ownsource";
+ var manifest = new AdapterManifest { Id = id, Entry = Sample + ".dll" };
+ await Repository(bucket).PublishVersionAsync(id, "1.0.0",
+ Package(manifest, new Dictionary { ["source/needed-at-runtime.txt"] = "keep me" }),
+ new PackageInfo { EntryAssembly = Sample + ".dll", Manifest = manifest }, promote: true, "compat");
+
+ using var host = Compat.NewHost(bucket);
+ var installed = await host.GetRequiredService().InstallAsync(id);
+
+ Assert.AreEqual("keep me", await File.ReadAllTextAsync(Path.Combine(installed.Directory, "source", "needed-at-runtime.txt")));
+ }
+
+ [TestMethod]
+ public async Task An_adapter_in_another_runtime_is_out_of_old_hosts_sight_and_new_hosts_find_its_current_version()
+ {
+ using var bucket = new Bucket();
+ const string id = "layout.python";
+ var repository = Repository(bucket);
+ var package = new PackageInfo { EntryAssembly = "main.py", Manifest = Python(id) };
+ await repository.PublishVersionAsync(id, "1.0.0", Package(Python(id)), package, promote: true, "compat");
+ await repository.PublishVersionAsync(id, "1.1.0", Package(Python(id)), package, promote: false, "compat");
+
+ Assert.AreEqual(0, (await bucket.KeysAsync("adapters/")).Count, "nothing under adapters/, where old hosts and old Bitween look");
+ Assert.AreEqual("1.0.0", (await repository.LoadEntryAsync(id)).Current);
+
+ var old = await Compat.RunOldHostAsync(bucket, id, "--command", "Echo", "--input", "x");
+ Assert.AreNotEqual(0, old.ExitCode, "an old host must not find it, let alone start it with dotnet");
+
+ using (var host = Compat.NewHost(bucket))
+ Assert.AreEqual($"adapters-versions/{id}/1.0.0",
+ (await host.GetRequiredService().GetMetadataAsync(id)).RemoteKey);
+
+ await repository.PromoteAsync(id, "1.1.0", Path.Combine(bucket.Path, "..", "work-" + Guid.NewGuid().ToString("N")));
+
+ Assert.AreEqual(0, (await bucket.KeysAsync("adapters/")).Count, "promote doesn't put it there either");
+ Assert.AreEqual("1.1.0", (await repository.LoadEntryAsync(id)).Current);
+ using (var host = Compat.NewHost(bucket))
+ Assert.AreEqual($"adapters-versions/{id}/1.1.0",
+ (await host.GetRequiredService().GetMetadataAsync(id)).RemoteKey);
+ }
+
+ [TestMethod]
+ public async Task An_id_published_as_dotnet_cannot_move_to_another_runtime()
+ {
+ using var bucket = new Bucket();
+ const string id = "layout.switch";
+ var repository = Repository(bucket);
+ await repository.PublishVersionAsync(id, "1.0.0", Package(new AdapterManifest { Id = id, Entry = Sample + ".dll" }),
+ new PackageInfo { EntryAssembly = Sample + ".dll" }, promote: true, "compat");
+
+ var refused = await Assert.ThrowsExceptionAsync(() => repository.PublishVersionAsync(id, "2.0.0",
+ Package(Python(id)), new PackageInfo { EntryAssembly = "main.py", Manifest = Python(id) }, promote: true, "compat"));
+ StringAssert.Contains(refused.Message, "new id");
+ Assert.AreEqual("1.0.0", (await repository.LoadEntryAsync(id)).Current);
+ }
+
+ [TestMethod]
+ public async Task An_adapter_in_another_runtime_must_be_published_with_a_version()
+ {
+ using var bucket = new Bucket();
+ var refused = await Assert.ThrowsExceptionAsync(() => Repository(bucket).PublishUnversionedAsync("layout.unversioned",
+ Package(Python("layout.unversioned")), new PackageInfo { EntryAssembly = "main.py", Manifest = Python("layout.unversioned") }));
+
+ StringAssert.Contains(refused.Message, "with a version");
+ Assert.AreEqual(0, (await bucket.KeysAsync()).Count);
+ }
+}
diff --git a/SW.Serverless.CompatibilityTests/SW.Serverless.CompatibilityTests.csproj b/SW.Serverless.CompatibilityTests/SW.Serverless.CompatibilityTests.csproj
index c93e8aa..8910b0a 100644
--- a/SW.Serverless.CompatibilityTests/SW.Serverless.CompatibilityTests.csproj
+++ b/SW.Serverless.CompatibilityTests/SW.Serverless.CompatibilityTests.csproj
@@ -30,6 +30,8 @@
+
A self-contained executable — Go, Rust, a native-compiled .NET app.
+ public const string ExecRuntime = "exec";
+ public const string PythonRuntime = "python";
+ public const string NodeRuntime = "node";
public int ManifestVersion { get; set; } = CurrentManifestVersion;
@@ -78,6 +82,31 @@ public class AdapterManifest
/// The file the runtime starts, relative to the package root.
public string Entry { get; set; }
+ ///
+ /// The runtime versions it needs, e.g. ">=3.12" for Python. A host refuses to install it
+ /// when the runtime it has is outside. Absent means any.
+ ///
+ public string RuntimeVersion { get; set; }
+
+ ///
+ /// The platforms it runs on — linux-x64, linux-arm64, osx-arm64, win-x64 — for a package
+ /// with platform-specific contents, such as an exec binary or Python wheels with native code.
+ /// Absent means it runs anywhere its runtime does.
+ ///
+ public List Platforms { get; set; }
+
+ ///
+ /// A per-platform , for a package carrying one build per platform.
+ /// stays the default, and stays what readers before this field use.
+ ///
+ public Dictionary Entries { get; set; }
+
+ /// The contracts it implements and their versions, e.g. bitween → 1.
+ public Dictionary Contracts { get; set; }
+
+ /// The source the package was built from, when it carries it.
+ public AdapterSource Source { get; set; }
+
/// or .
public string Lifecycle { get; set; } = ClassicLifecycle;
@@ -116,6 +145,10 @@ public static AdapterManifest Parse(string json) =>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
+ /// What the runtime starts on : its own entry, or the default.
+ public string EntryFor(string platform) =>
+ platform != null && Entries != null && Entries.TryGetValue(platform, out var entry) ? entry : Entry;
+
public bool IsResident =>
string.Equals(Lifecycle, ResidentLifecycle, StringComparison.OrdinalIgnoreCase);
@@ -123,6 +156,9 @@ public static AdapterManifest Parse(string json) =>
static readonly Regex IdPattern = new(@"^[a-z0-9][a-z0-9._-]*$");
static readonly Regex VersionPattern = new(@"^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$");
+ static readonly Regex RuntimePattern = new(@"^[a-z][a-z0-9.-]*$");
+ static readonly Regex PlatformPattern = new(@"^[a-z]+-[a-z0-9]+$");
+ static readonly Regex Sha256Pattern = new(@"^[0-9a-fA-F]{64}$");
static readonly Regex PropertyNamePattern = new(@"^[A-Za-z_][A-Za-z0-9_.:-]*$");
/// What is wrong with this manifest, empty when nothing is.
@@ -140,6 +176,33 @@ public IReadOnlyList Validate()
problems.Add($"entry '{Entry}' must be a path inside the package.");
if (!string.IsNullOrEmpty(Icon) && !IsPackagePath(Icon))
problems.Add($"icon '{Icon}' must be a path inside the package.");
+ if (!string.IsNullOrEmpty(Runtime) && !RuntimePattern.IsMatch(Runtime))
+ problems.Add($"runtime '{Runtime}' must be a name such as dotnet, exec, python or node.");
+ foreach (var platform in Platforms ?? new List())
+ if (!PlatformPattern.IsMatch(platform ?? ""))
+ problems.Add($"platform '{platform}' must look like linux-x64 or osx-arm64.");
+ foreach (var (platform, entry) in Entries ?? new Dictionary())
+ {
+ if (Platforms == null || !Platforms.Contains(platform))
+ problems.Add($"entries names platform '{platform}', which platforms doesn't list.");
+ if (!IsPackagePath(entry))
+ problems.Add($"entries.{platform} '{entry}' must be a path inside the package.");
+ }
+ foreach (var (contract, version) in Contracts ?? new Dictionary())
+ if (string.IsNullOrWhiteSpace(contract) || version < 1)
+ problems.Add($"contract '{contract}' needs a name and a version of 1 or more.");
+ if (Source != null)
+ {
+ if (!IsPackagePath(Source.Path ?? ""))
+ problems.Add($"source.path '{Source.Path}' must be a folder inside the package.");
+ foreach (var (file, hash) in Source.Files ?? new Dictionary())
+ {
+ if (!IsPackagePath(file))
+ problems.Add($"source file '{file}' must be a path inside the source folder.");
+ if (!Sha256Pattern.IsMatch(hash ?? ""))
+ problems.Add($"source file '{file}' needs its SHA-256 as 64 hex digits.");
+ }
+ }
if (!string.IsNullOrEmpty(Lifecycle) && Lifecycle is not (ClassicLifecycle or ResidentLifecycle))
problems.Add($"lifecycle '{Lifecycle}' must be '{ClassicLifecycle}' or '{ResidentLifecycle}'.");
if (Protocol != null && Protocol.Min > Protocol.Max)
@@ -192,6 +255,32 @@ public class AdapterProtocolRange
public Dictionary Extensions { get; set; }
}
+ ///
+ /// The source a package carries, so a published version can be read, compared, audited and
+ /// rebuilt. The files themselves sit in the package under ; hosts don't
+ /// extract them.
+ ///
+ public class AdapterSource
+ {
+ public const string DefaultPath = "source";
+
+ /// The folder inside the package that holds the source.
+ public string Path { get; set; } = DefaultPath;
+
+ /// Every source file, relative to , with its SHA-256 in hex.
+ public Dictionary Files { get; set; } = new();
+
+ /// How the package was built from the source, e.g. "dotnet publish -c Release".
+ public string BuildCommand { get; set; }
+
+ /// The lockfiles the build resolved dependencies from, relative to .
+ public List Lockfiles { get; set; }
+
+ /// Fields written by a newer tool, kept so they are not lost on a round trip.
+ [JsonExtensionData]
+ public Dictionary Extensions { get; set; }
+ }
+
/// The oldest host each side may run on. Absent means no constraint.
public class AdapterCompatibility
{
diff --git a/SW.Serverless.Contract/Catalog/AdapterSelfDescription.cs b/SW.Serverless.Contract/Catalog/AdapterSelfDescription.cs
new file mode 100644
index 0000000..b7f09cc
--- /dev/null
+++ b/SW.Serverless.Contract/Catalog/AdapterSelfDescription.cs
@@ -0,0 +1,80 @@
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace SW.Serverless.Contract.Catalog
+{
+ ///
+ /// What an adapter says about itself when started with : printed as one JSON
+ /// document on stdout, after which it exits. Every SDK, in every language, produces this, so the
+ /// tools learn an adapter's settings, commands, kinds and contracts by running it — not by
+ /// reading .NET assemblies — and write them into its manifest.
+ ///
+ public class AdapterSelfDescription
+ {
+ public const string Flag = "--describe";
+ public const int CurrentDescribeVersion = 1;
+
+ public int DescribeVersion { get; set; } = CurrentDescribeVersion;
+
+ /// dotnet, python, node, go…
+ public string SdkLanguage { get; set; }
+ public string SdkVersion { get; set; }
+
+ /// classic or resident: how the adapter's entry point runs it.
+ public string Lifecycle { get; set; }
+
+ /// The protocol versions it speaks: 1 is the classic text protocol, 2 is gRPC.
+ public AdapterProtocolRange Protocol { get; set; }
+
+ public List Settings { get; set; } = new();
+ public List Commands { get; set; } = new();
+ public List Kinds { get; set; } = new();
+ public Dictionary Contracts { get; set; } = new();
+
+ /// Anything that kept the description from being complete, e.g. a handler that couldn't be built without its settings.
+ public List Warnings { get; set; } = new();
+
+ [JsonExtensionData]
+ public Dictionary Extensions { get; set; }
+
+ public static AdapterSelfDescription Parse(string json) =>
+ JsonSerializer.Deserialize(json, AdapterManifest.JsonOptions)
+ ?? throw new JsonException("The adapter's description is empty.");
+
+ public string ToJson() => JsonSerializer.Serialize(this, AdapterManifest.JsonOptions);
+ }
+
+ public class DescribedSetting
+ {
+ public string Name { get; set; }
+ public string Description { get; set; }
+
+ /// text, multiline, number, boolean, select or json, as manifest properties are typed.
+ public string Type { get; set; } = AdapterProperty.TextType;
+
+ public bool Required { get; set; }
+ public bool Secret { get; set; }
+ public string Default { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary Extensions { get; set; }
+ }
+
+ public class DescribedCommand
+ {
+ public string Name { get; set; }
+ public string Description { get; set; }
+
+ /// JSON Schema of the argument; null when it takes none.
+ public JsonElement? InputSchema { get; set; }
+
+ /// JSON Schema of the result; null when it returns nothing.
+ public JsonElement? OutputSchema { get; set; }
+
+ public bool ReturnsValue { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary Extensions { get; set; }
+ }
+}
diff --git a/SW.Serverless.Contract/Protos/adapter.proto b/SW.Serverless.Contract/Protos/adapter.proto
index 681a408..f692739 100644
--- a/SW.Serverless.Contract/Protos/adapter.proto
+++ b/SW.Serverless.Contract/Protos/adapter.proto
@@ -118,6 +118,36 @@ message Hello {
// adapters and hosts built before this field existed; a host that understands this one gets
// the shape as well as the name.
repeated CommandInfo commands = 7;
+
+ // Fields 8–11 describe the adapter without .NET in the picture, for SDKs in any language. All
+ // optional: a host reads them when present and an adapter built before them leaves them out.
+
+ // The SDK's language — dotnet, python, node, go — beside sdk_version.
+ string sdk_language = 8;
+
+ // The settings the adapter reads, the language-neutral form of Runner.Expect: what a host or a
+ // UI needs to ask for before running it.
+ repeated SettingInfo settings = 9;
+
+ // The kinds of adapter it implements for a contract, as that contract names them — for
+ // Bitween: handler, mapper, validator, receiver.
+ repeated string kinds = 10;
+
+ // The contracts it implements and their versions, e.g. bitween -> 1.
+ map contracts = 11;
+}
+
+// One setting an adapter reads.
+message SettingInfo {
+ string name = 1;
+ string description = 2;
+ bool required = 3;
+ // Secret settings are masked wherever they're shown and never logged.
+ bool secret = 4;
+ // Empty when the setting has no default.
+ string default_value = 5;
+ // text, multiline, number, boolean, select or json, as adapter manifests type properties.
+ string type = 6;
}
// One callable command on an adapter.
@@ -140,6 +170,11 @@ message CommandInfo {
// From [AdapterCommand(Description = "…")] on the method. Optional, and worth setting: the
// difference between a list of names and a list an operator can act on.
string description = 5;
+
+ // JSON Schemas of the argument and the result, for SDKs in any language: parameter_type and
+ // parameter_schema above are .NET's view and stay as they are. Empty when not given.
+ string input_schema = 6;
+ string output_schema = 7;
}
message InvokeResult {
diff --git a/SW.Serverless.Desktop/MainWindow.xaml.cs b/SW.Serverless.Desktop/MainWindow.xaml.cs
index ffa21ed..c79eaa1 100644
--- a/SW.Serverless.Desktop/MainWindow.xaml.cs
+++ b/SW.Serverless.Desktop/MainWindow.xaml.cs
@@ -1,6 +1,6 @@
using Microsoft.Win32;
using Newtonsoft.Json;
-using SW.Serverless.Installer.Shared;
+using SW.Serverless.Tooling;
using System;
using System.Collections.Generic;
using System.IO;
diff --git a/SW.Serverless.Installer.UnitTests/AdapterDescriberTests.cs b/SW.Serverless.Installer.UnitTests/AdapterDescriberTests.cs
index c51c20a..96e3832 100644
--- a/SW.Serverless.Installer.UnitTests/AdapterDescriberTests.cs
+++ b/SW.Serverless.Installer.UnitTests/AdapterDescriberTests.cs
@@ -2,7 +2,7 @@
using System.IO;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
-using SW.Serverless.Installer.Shared;
+using SW.Serverless.Tooling;
namespace SW.Serverless.Installer.UnitTests;
diff --git a/SW.Serverless.Installer.UnitTests/BuildTests.cs b/SW.Serverless.Installer.UnitTests/BuildTests.cs
new file mode 100644
index 0000000..27fe5e0
--- /dev/null
+++ b/SW.Serverless.Installer.UnitTests/BuildTests.cs
@@ -0,0 +1,244 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using SW.Serverless.Contract.Catalog;
+using SW.Serverless.Tooling.Building;
+using SW.Serverless.Tooling.Conformance;
+
+namespace SW.Serverless.Installer.UnitTests;
+
+[TestClass]
+public class IgnoreRuleTests
+{
+ [DataTestMethod]
+ [DataRow("bin/Debug/net10.0/a.dll", true)]
+ [DataRow("src/obj/project.assets.json", true)]
+ [DataRow("node_modules/x/index.js", true)]
+ [DataRow(".env", true)]
+ [DataRow("config/.env.production", true)]
+ [DataRow("keys/partner.pem", true)]
+ [DataRow("appsettings.Production.json", true)]
+ [DataRow("appsettings.json", false)]
+ [DataRow("Handler.cs", false)]
+ [DataRow("src/binder/Thing.cs", false)]
+ public void Built_in_rules(string path, bool ignored) =>
+ Assert.AreEqual(ignored, new IgnoreRules(IgnoreRules.BuiltIn).Ignores(path), path);
+
+ [TestMethod]
+ public void A_project_s_own_patterns_add_to_them_and_can_bring_a_file_back()
+ {
+ var rules = new IgnoreRules(IgnoreRules.BuiltIn.Concat(new[] { "fixtures/**/*.zip", "/local.txt", "*.log", "!keep.log", "!keys/public.pem" }));
+
+ Assert.IsTrue(rules.Ignores("fixtures/a/b/big.zip"));
+ Assert.IsTrue(rules.Ignores("fixtures/big.zip"));
+ Assert.IsTrue(rules.Ignores("local.txt"));
+ Assert.IsFalse(rules.Ignores("sub/local.txt"), "a leading slash anchors to the root");
+ Assert.IsTrue(rules.Ignores("logs/run.log"));
+ Assert.IsFalse(rules.Ignores("keep.log"));
+ Assert.IsFalse(rules.Ignores("keys/public.pem"));
+ }
+}
+
+[TestClass]
+public class SecretScannerTests
+{
+ [DataTestMethod]
+ [DataRow("-----BEGIN RSA PRIVATE KEY-----", "a private key")]
+ [DataRow("-----BEGIN OPENSSH PRIVATE KEY-----", "a private key")]
+ [DataRow("var key = \"AKIAIOSFODNN7EXAMPLE\";", "an AWS access key")]
+ [DataRow("\"Db\": \"Server=x;User Id=sa;Password=Sup3rS3cret!;\"", "a password in a connection string")]
+ [DataRow("const apiKey = \"9f8e7d6c5b4a3f2e1d0c\";", "a secret assigned in code")]
+ [DataRow("client_secret: 'abcdefgh12345678'", "a secret assigned in code")]
+ [DataRow("token = ghp_abcdefghijklmnopqrstuvwxyz0123456789AB", "a GitHub token")]
+ public void A_secret_is_found_and_named(string line, string kind)
+ {
+ var finding = SecretScanner.Scan("Handler.cs", "first line\n" + line + "\n").Single();
+ Assert.AreEqual(2, finding.Line);
+ Assert.AreEqual(kind, finding.Kind);
+ }
+
+ [DataTestMethod]
+ [DataRow("Runner.Expect(\"Password\", isPrivate: true);")]
+ [DataRow("var password = Runner.StartupValueOf(\"Password\");")]
+ [DataRow("\"Password\": \"********\"")]
+ [DataRow("apiKey = \"your-api-key-here\"")]
+ [DataRow("secret: \"${SECRET}\"")]
+ [DataRow("Password={password};")]
+ public void Code_that_only_names_or_reads_a_secret_is_not_reported(string line) =>
+ Assert.AreEqual(0, SecretScanner.Scan("Handler.cs", line).Count(), line);
+}
+
+///
+/// serverless build on a real adapter project: built, described, its manifest written from the
+/// adapter itself, its source carried under the rules — and the package it makes is what the
+/// conformance kit and any host run.
+///
+[TestClass]
+public class BuildTests
+{
+ static string RepositoryRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null && !File.Exists(Path.Combine(dir.FullName, "SW.Serverless.sln"))) dir = dir.Parent;
+ return dir!.FullName;
+ }
+
+ ///
+ /// A copy of the BitweenHandler test adapter as an author's project: inside the repository, so
+ /// its reference to the SDK project still resolves, with an adapter.json and a few files the
+ /// source rules must leave out.
+ ///
+ static string AuthorProject(string authorJson = null, IDictionary extraFiles = null)
+ {
+ var root = RepositoryRoot();
+ var project = Path.Combine(root, ".test-work", Guid.NewGuid().ToString("N"), "AcmeOrders");
+ Directory.CreateDirectory(project);
+ var sdk = Path.GetRelativePath(project, Path.Combine(root, "SW.Serverless.Sdk", "SW.Serverless.Sdk.csproj"));
+ File.WriteAllText(Path.Combine(project, "AcmeOrders.csproj"), $"""
+
+
+ Exe
+ net10.0
+
+
+
+
+
+ """);
+ File.Copy(Path.Combine(root, "SW.Serverless.UnitTests.BitweenHandler", "Program.cs"), Path.Combine(project, "Program.cs"));
+ File.WriteAllText(Path.Combine(project, AdapterManifest.FileName), authorJson ??
+ """{ "id": "acme.orders", "version": "1.2.0", "displayName": "Acme orders", "properties": [ { "name": "Mode", "type": "select", "options": ["working", "broken"] } ] }""");
+ File.WriteAllText(Path.Combine(project, ".env"), "API_KEY=never-carried");
+ File.WriteAllText(Path.Combine(project, "notes.log"), "left out by .serverlessignore");
+ File.WriteAllText(Path.Combine(project, ".serverlessignore"), "*.log\n");
+ foreach (var (name, content) in extraFiles ?? new Dictionary())
+ File.WriteAllText(Path.Combine(project, name), content);
+ return project;
+ }
+
+ [ClassCleanup]
+ public static void Cleanup()
+ {
+ try { Directory.Delete(Path.Combine(RepositoryRoot(), ".test-work"), true); } catch { }
+ }
+
+ static Task Build(string project, Action configure = null)
+ {
+ var request = new BuildRequest { ProjectDirectory = project, Log = Console.WriteLine };
+ configure?.Invoke(request);
+ return PackageBuilder.BuildAsync(request);
+ }
+
+ [TestMethod]
+ public async Task A_build_writes_the_manifest_from_the_adapter_and_carries_its_source_under_the_rules()
+ {
+ var project = AuthorProject();
+ var result = await Build(project);
+ Assert.IsTrue(result.Succeeded, string.Join("; ", result.Problems));
+
+ var manifest = result.Manifest;
+ Assert.AreEqual("acme.orders", manifest.Id);
+ Assert.AreEqual("1.2.0", manifest.Version);
+ Assert.AreEqual("Acme orders", manifest.DisplayName, "the author's text is kept");
+ Assert.AreEqual("dotnet", manifest.Runtime);
+ Assert.AreEqual("AcmeOrders.dll", manifest.Entry);
+ Assert.AreEqual("10.1.0", manifest.SdkVersion);
+ Assert.AreEqual(AdapterManifest.ClassicLifecycle, manifest.Lifecycle);
+ CollectionAssert.AreEqual(new[] { "handler" }, manifest.Kinds);
+ Assert.AreEqual(1, manifest.Contracts!["bitween"]);
+
+ var mode = manifest.Properties.Single(p => p.Name == "Mode");
+ Assert.AreEqual("select", mode.Type, "the author's presentation is kept");
+ Assert.AreEqual("working", mode.Default, "the adapter's default wins");
+ var apiKey = manifest.Properties.Single(p => p.Name == "ApiKey");
+ Assert.IsTrue(apiKey.Required);
+ Assert.IsTrue(apiKey.Secret);
+ Assert.IsNull(apiKey.Default);
+
+ var carried = manifest.Source!.Files.Keys.ToList();
+ Assert.IsTrue(carried.Any(f => f.EndsWith("AcmeOrders/Program.cs")), string.Join(", ", carried));
+ Assert.IsTrue(carried.Any(f => f.EndsWith("AcmeOrders/AcmeOrders.csproj")));
+ Assert.IsTrue(carried.Any(f => f.EndsWith("SW.Serverless.Sdk/Runner.cs")), "the local project it references, so it rebuilds");
+ Assert.IsFalse(carried.Any(f => f.EndsWith(".env") || f.EndsWith("notes.log") || f.Contains("/bin/") || f.Contains("/obj/")),
+ string.Join(", ", carried.Where(f => f.EndsWith(".env") || f.EndsWith(".log") || f.Contains("/bin/") || f.Contains("/obj/"))));
+
+ using var zip = ZipFile.OpenRead(result.ZipPath);
+ var names = zip.Entries.Select(e => e.FullName.Replace('\\', '/')).ToList();
+ CollectionAssert.Contains(names, "adapter.json");
+ CollectionAssert.Contains(names, "AcmeOrders.dll");
+ Assert.IsTrue(names.Any(n => n.StartsWith("source/") && n.EndsWith("AcmeOrders/Program.cs")));
+ Assert.IsTrue(result.ZipPath.EndsWith("acme.orders-1.2.0.zip"));
+ }
+
+ [TestMethod]
+ public async Task What_a_build_makes_passes_the_conformance_kit()
+ {
+ var result = await Build(AuthorProject());
+ Assert.IsTrue(result.Succeeded, string.Join("; ", result.Problems));
+
+ var report = await new ConformanceRunner().RunAsync(new ConformanceOptions
+ {
+ PackageDirectory = result.PackageDirectory,
+ Settings = new Dictionary { ["ApiKey"] = "k" },
+ });
+ Assert.IsTrue(report.Passed, string.Join("; ", report.Checks.Where(c => c.Outcome == CheckOutcome.Failed)));
+ }
+
+ [TestMethod]
+ public async Task A_secret_in_the_source_stops_the_build_until_it_is_allowed()
+ {
+ var project = AuthorProject(extraFiles: new Dictionary
+ {
+ ["Defaults.cs"] = "class Defaults { const string ApiKey = \"live-9f8e7d6c5b4a3f2e\"; }",
+ });
+
+ var stopped = await Build(project);
+ Assert.IsFalse(stopped.Succeeded);
+ var problem = stopped.Problems.Single();
+ StringAssert.Contains(problem, "Defaults.cs:1");
+ StringAssert.Contains(problem, "--allow");
+ Assert.IsNull(stopped.ZipPath, "nothing was built");
+
+ var allowed = await Build(project, r => r.AllowedFiles.Add("Defaults.cs"));
+ Assert.IsTrue(allowed.Succeeded, string.Join("; ", allowed.Problems));
+ }
+
+ [TestMethod]
+ public async Task Without_source_the_package_carries_none()
+ {
+ var result = await Build(AuthorProject(), r => r.IncludeSource = false);
+
+ Assert.IsTrue(result.Succeeded, string.Join("; ", result.Problems));
+ Assert.IsNull(result.Manifest.Source);
+ Assert.IsFalse(Directory.Exists(Path.Combine(result.PackageDirectory, "source")));
+ }
+
+ [TestMethod]
+ public async Task A_dry_run_reports_the_source_and_builds_nothing()
+ {
+ var project = AuthorProject();
+ var result = await Build(project, r => r.DryRun = true);
+
+ Assert.IsTrue(result.Succeeded);
+ Assert.IsTrue(result.SourceFiles.Any(f => f.Path.EndsWith("Program.cs")));
+ Assert.IsNull(result.ZipPath);
+ Assert.IsFalse(Directory.Exists(Path.Combine(project, "bin", "serverless")));
+ }
+
+ [TestMethod]
+ public async Task A_gRPC_adapter_the_author_marks_classic_is_built_to_run_classically_over_gRPC()
+ {
+ var root = RepositoryRoot();
+ var project = AuthorProject("""{ "id": "acme.grpc", "lifecycle": "classic" }""");
+ File.Copy(Path.Combine(root, "SW.Serverless.UnitTests.GrpcClassicAdapter", "Program.cs"), Path.Combine(project, "Program.cs"), overwrite: true);
+
+ var result = await Build(project);
+ Assert.IsTrue(result.Succeeded, string.Join("; ", result.Problems));
+ Assert.AreEqual(AdapterManifest.ClassicLifecycle, result.Manifest.Lifecycle);
+ Assert.AreEqual(2, result.Manifest.Protocol!.Min);
+ }
+}
diff --git a/SW.Serverless.Installer.UnitTests/CatalogPublishingTests.cs b/SW.Serverless.Installer.UnitTests/CatalogPublishingTests.cs
index 3902b29..35bd4b6 100644
--- a/SW.Serverless.Installer.UnitTests/CatalogPublishingTests.cs
+++ b/SW.Serverless.Installer.UnitTests/CatalogPublishingTests.cs
@@ -7,7 +7,7 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using SW.PrimitiveTypes;
using SW.Serverless.Contract.Catalog;
-using SW.Serverless.Installer.Shared;
+using SW.Serverless.Tooling;
namespace SW.Serverless.Installer.UnitTests;
diff --git a/SW.Serverless.Installer.UnitTests/CliCommandTests.cs b/SW.Serverless.Installer.UnitTests/CliCommandTests.cs
new file mode 100644
index 0000000..f894bb2
--- /dev/null
+++ b/SW.Serverless.Installer.UnitTests/CliCommandTests.cs
@@ -0,0 +1,189 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using SW.Serverless.Contract.Catalog;
+using SW.Serverless.Tooling;
+
+namespace SW.Serverless.Installer.UnitTests;
+
+///
+/// The commands for adapters in any language, run through the CLI's own entry point as a person or
+/// a CI job runs them: init, build, test, run, manifest validate and publish.
+///
+[TestClass]
+public class CliCommandTests
+{
+ static string RepositoryRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null && !File.Exists(Path.Combine(dir.FullName, "SW.Serverless.sln"))) dir = dir.Parent;
+ return dir!.FullName;
+ }
+
+ static string WorkFolder()
+ {
+ var folder = Path.Combine(RepositoryRoot(), ".test-work", "cli-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(folder);
+ return folder;
+ }
+
+ [ClassCleanup]
+ public static void Cleanup()
+ {
+ try { Directory.Delete(Path.Combine(RepositoryRoot(), ".test-work"), true); } catch { }
+ }
+
+ /// Runs the CLI and returns its exit code and what it printed.
+ static async Task<(int Exit, string Output)> Cli(params string[] args)
+ {
+ var original = Console.Out;
+ using var output = new StringWriter();
+ Console.SetOut(output);
+ try
+ {
+ var exit = await Program.RunAsync(args, _ => null);
+ return (exit, output.ToString());
+ }
+ finally
+ {
+ Console.SetOut(original);
+ }
+ }
+
+ [TestMethod]
+ public async Task Init_makes_a_project_and_refuses_what_it_can_t_make()
+ {
+ var work = WorkFolder();
+
+ var (exit, output) = await Cli("init", "AcmeOrders", "--kind", "validator", "--dir", work);
+ Assert.AreEqual(Program.Success, exit, output);
+ var project = Path.Combine(work, "AcmeOrders");
+ foreach (var file in new[] { "AcmeOrders.csproj", "Program.cs", "adapter.json", "settings.example.json", ".gitignore", "README.md" })
+ Assert.IsTrue(File.Exists(Path.Combine(project, file)), file);
+ Assert.AreEqual("acme.orders", AdapterManifest.Parse(File.ReadAllText(Path.Combine(project, "adapter.json"))).Id);
+ StringAssert.Contains(File.ReadAllText(Path.Combine(project, "Program.cs")), "IBitweenValidator");
+
+ Assert.AreEqual(Program.Failure, (await Cli("init", "AcmeOrders", "--dir", work)).Exit, "an existing project isn't overwritten");
+ var (pythonExit, pythonOutput) = await Cli("init", "PyOrders", "--lang", "python", "--dir", work);
+ Assert.AreEqual(Program.Failure, pythonExit);
+ StringAssert.Contains(pythonOutput, "arrives with that language's SDK");
+ }
+
+ [TestMethod]
+ public async Task Manifest_validate_passes_a_good_file_and_names_what_is_wrong_with_a_bad_one()
+ {
+ var work = WorkFolder();
+ File.WriteAllText(Path.Combine(work, "adapter.json"), """{ "id": "good.adapter", "version": "1.0.0" }""");
+ Assert.AreEqual(Program.Success, (await Cli("manifest", "validate", work)).Exit);
+
+ File.WriteAllText(Path.Combine(work, "adapter.json"), """{ "id": "Bad Id", "runtime": "../sh" }""");
+ var (exit, output) = await Cli("manifest", "validate", work);
+ Assert.AreEqual(Program.Failure, exit);
+ StringAssert.Contains(output, "id 'Bad Id'");
+ StringAssert.Contains(output, "runtime '../sh'");
+ }
+
+ /// An author's project, as BuildTests makes one: the BitweenHandler adapter with an adapter.json.
+ static string AuthorProject()
+ {
+ var root = RepositoryRoot();
+ var project = Path.Combine(WorkFolder(), "AcmeOrders");
+ Directory.CreateDirectory(project);
+ var sdk = Path.GetRelativePath(project, Path.Combine(root, "SW.Serverless.Sdk", "SW.Serverless.Sdk.csproj"));
+ File.WriteAllText(Path.Combine(project, "AcmeOrders.csproj"), $"""
+
+
+ Exe
+ net10.0
+
+
+
+
+
+ """);
+ File.Copy(Path.Combine(root, "SW.Serverless.UnitTests.BitweenHandler", "Program.cs"), Path.Combine(project, "Program.cs"));
+ File.WriteAllText(Path.Combine(project, "adapter.json"), """{ "id": "acme.orders", "version": "1.2.0", "displayName": "Acme orders" }""");
+ return project;
+ }
+
+ [TestMethod]
+ public async Task Build_test_run_and_publish_take_a_project_into_storage()
+ {
+ var project = AuthorProject();
+ var settings = Path.Combine(Path.GetDirectoryName(project)!, "settings.json");
+ File.WriteAllText(settings, """{ "ApiKey": "test-key" }""");
+
+ var build = await Cli("build", project);
+ Assert.AreEqual(Program.Success, build.Exit, build.Output);
+ StringAssert.Contains(build.Output, "Source carried");
+ var zip = Path.Combine(project, "bin", "serverless", "acme.orders-1.2.0.zip");
+ Assert.IsTrue(File.Exists(zip), build.Output);
+
+ var test = await Cli("test", zip, "--settings", settings);
+ Assert.AreEqual(Program.Success, test.Exit, test.Output);
+ StringAssert.Contains(test.Output, "PASS bitween handler: Handle answers example 1");
+ StringAssert.Contains(test.Output, "Conforms.");
+
+ var run = await Cli("run", zip, "--settings", settings, "--call", "Handle", "--input", """{"Data":"{\"order\":1}"}""");
+ Assert.AreEqual(Program.Success, run.Exit, run.Output);
+ StringAssert.Contains(run.Output, "accepted");
+
+ var store = Path.Combine(Path.GetDirectoryName(project)!, "store");
+ var publish = await Cli("publish", zip, "-p", "local", "-b", "cli-tests", "-u", store);
+ Assert.AreEqual(Program.Success, publish.Exit, publish.Output);
+ StringAssert.Contains(publish.Output, "Published acme.orders 1.2.0");
+
+ var repository = new AdapterRepository(CloudFilesFactory.Create(new ServerlessUploadOptions
+ {
+ Provider = "local", BucketName = "cli-tests", ServiceUrl = store,
+ }), _ => { });
+ var entry = await repository.LoadEntryAsync("acme.orders");
+ Assert.AreEqual("1.2.0", entry.Current);
+ Assert.IsNotNull(entry.Manifest!.PublishedOn);
+ Assert.IsNotNull(entry.Manifest.Source, "it carries its source into storage");
+
+ // Published again with a bump: the version moves on, the old one stays.
+ var again = await Cli("publish", zip, "-v", "minor", "-p", "local", "-b", "cli-tests", "-u", store);
+ Assert.AreEqual(Program.Success, again.Exit, again.Output);
+ Assert.AreEqual("1.3.0", (await repository.LoadEntryAsync("acme.orders")).Current);
+ }
+
+ ///
+ /// What init writes builds and conforms. The templates reference the published SDK and Bitween
+ /// contract packages; until those are on NuGet the project is pointed at the projects themselves,
+ /// with Bitween-api beside this repository.
+ ///
+ [DataTestMethod]
+ [DataRow("handler")]
+ [DataRow("receiver")]
+ [DataRow("validator")]
+ public async Task What_init_writes_builds_and_conforms(string kind)
+ {
+ var root = RepositoryRoot();
+ var contracts = Path.GetFullPath(Path.Combine(root, "..", "Bitween-api", "SW.Bitween.Adapters", "SW.Bitween.Adapters.csproj"));
+ if (!File.Exists(contracts)) Assert.Inconclusive($"Bitween-api isn't beside this repository ({contracts})");
+
+ var work = WorkFolder();
+ Assert.AreEqual(Program.Success, (await Cli("init", "Acme" + char.ToUpper(kind[0]) + kind[1..], "--kind", kind, "--dir", work)).Exit);
+ var project = Directory.GetDirectories(work).Single();
+ var csproj = Directory.GetFiles(project, "*.csproj").Single();
+ var text = File.ReadAllText(csproj)
+ .Replace($"",
+ $"")
+ .Replace($"",
+ $"")
+ .Replace("true", "");
+ File.WriteAllText(csproj, text);
+
+ var build = await Cli("build", project, "--no-source");
+ Assert.AreEqual(Program.Success, build.Exit, build.Output);
+
+ var settings = Path.Combine(work, "settings.json");
+ File.WriteAllText(settings, """{ "ApiKey": "k" }""");
+ var test = await Cli("test", Path.Combine(project, "bin", "serverless", "package"), "--settings", settings);
+ Assert.AreEqual(Program.Success, test.Exit, test.Output);
+ }
+}
diff --git a/SW.Serverless.Installer.UnitTests/ConformanceTests.cs b/SW.Serverless.Installer.UnitTests/ConformanceTests.cs
new file mode 100644
index 0000000..a080e48
--- /dev/null
+++ b/SW.Serverless.Installer.UnitTests/ConformanceTests.cs
@@ -0,0 +1,169 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using SW.Serverless.Contract.Catalog;
+using SW.Serverless.Tooling.Conformance;
+
+namespace SW.Serverless.Installer.UnitTests;
+
+///
+/// The conformance kit — what serverless test runs — against real adapters built beside the tests:
+/// started as a host starts them, described, and called with the Bitween contract's examples.
+///
+[TestClass]
+public class ConformanceTests
+{
+ static string BuildOutput(string project)
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null && !File.Exists(Path.Combine(dir.FullName, "SW.Serverless.sln"))) dir = dir.Parent;
+ var bin = Path.Combine(dir!.FullName, project, "bin");
+ return Directory.GetDirectories(bin, "*", SearchOption.AllDirectories)
+ .Where(d => File.Exists(Path.Combine(d, project + ".dll")))
+ .OrderByDescending(d => File.GetLastWriteTimeUtc(Path.Combine(d, project + ".dll")))
+ .First();
+ }
+
+ /// A package: the project's build output with this manifest at its root.
+ static string Package(string project, AdapterManifest manifest)
+ {
+ var target = Path.Combine(Path.GetTempPath(), "swsl-conformance-tests", Guid.NewGuid().ToString("N"));
+ var source = BuildOutput(project);
+ foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
+ {
+ var to = Path.Combine(target, Path.GetRelativePath(source, file));
+ Directory.CreateDirectory(Path.GetDirectoryName(to)!);
+ File.Copy(file, to);
+ }
+ manifest.Entry ??= project + ".dll";
+ File.WriteAllText(Path.Combine(target, AdapterManifest.FileName), manifest.ToJson());
+ return target;
+ }
+
+ static AdapterManifest HandlerManifest(bool listApiKey = true)
+ {
+ var manifest = new AdapterManifest
+ {
+ Id = "test.bitween.handler",
+ Kinds = { "handler" },
+ Contracts = new() { ["bitween"] = 1 },
+ Properties =
+ {
+ new AdapterProperty { Name = "Endpoint", Default = "https://partner.example.test/orders", Description = "Where orders go." },
+ new AdapterProperty { Name = "Mode", Default = "working" },
+ },
+ };
+ if (listApiKey) manifest.Properties.Add(new AdapterProperty { Name = "ApiKey", Required = true, Secret = true });
+ return manifest;
+ }
+
+ static async Task RunAsync(string packageDirectory, IDictionary settings = null,
+ bool allowDelete = false)
+ {
+ var report = await new ConformanceRunner().RunAsync(new ConformanceOptions
+ {
+ PackageDirectory = packageDirectory,
+ Settings = settings ?? new Dictionary { ["ApiKey"] = "test-key" },
+ AllowDelete = allowDelete,
+ CommandTimeoutSeconds = 30,
+ });
+ Console.WriteLine(string.Join(Environment.NewLine, report.Checks.Select(c => $"{c.Outcome,-7} {c.Name} {c.Detail}")));
+ return report;
+ }
+
+ static ConformanceCheck Check(ConformanceReport report, string name) =>
+ report.Checks.Single(c => c.Name == name);
+
+ [TestMethod]
+ public async Task A_conforming_handler_passes_every_check()
+ {
+ var report = await RunAsync(Package("SW.Serverless.UnitTests.BitweenHandler", HandlerManifest()));
+
+ Assert.IsTrue(report.Passed, string.Join("; ", report.Checks.Where(c => c.Outcome == CheckOutcome.Failed)));
+ foreach (var name in new[]
+ {
+ "manifest", "describe", "settings match the manifest", "starts",
+ "bitween handler: methods", "bitween handler: Handle answers example 1", "an unknown command is refused",
+ })
+ Assert.AreEqual(CheckOutcome.Passed, Check(report, name).Outcome, name);
+ }
+
+ [TestMethod]
+ public async Task A_handler_answering_without_data_fails_the_contract()
+ {
+ var report = await RunAsync(Package("SW.Serverless.UnitTests.BitweenHandler", HandlerManifest()),
+ new Dictionary { ["ApiKey"] = "k", ["Mode"] = "broken" });
+
+ var check = Check(report, "bitween handler: Handle answers example 1");
+ Assert.AreEqual(CheckOutcome.Failed, check.Outcome);
+ StringAssert.Contains(check.Detail, "isn't a valid ExchangeFile");
+ Assert.IsFalse(report.Passed);
+ }
+
+ [TestMethod]
+ public async Task Settings_the_manifest_leaves_out_are_named()
+ {
+ var report = await RunAsync(Package("SW.Serverless.UnitTests.BitweenHandler", HandlerManifest(listApiKey: false)));
+
+ var check = Check(report, "settings match the manifest");
+ Assert.AreEqual(CheckOutcome.Failed, check.Outcome);
+ StringAssert.Contains(check.Detail, "'ApiKey' is declared by the adapter but missing from the manifest");
+ }
+
+ static (string Package, string Folder) Receiver()
+ {
+ var folder = Path.Combine(Path.GetTempPath(), "swsl-conformance-tests", "source-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(folder);
+ File.WriteAllText(Path.Combine(folder, "a.json"), "{\"n\":1}");
+ File.WriteAllText(Path.Combine(folder, "b.json"), "{\"n\":2}");
+
+ var package = Package("SW.Serverless.UnitTests.BitweenReceiver", new AdapterManifest
+ {
+ Id = "test.bitween.receiver",
+ Kinds = { "receiver" },
+ Contracts = new() { ["bitween"] = 1 },
+ // Run as Bitween runs receivers, classically, over gRPC.
+ Protocol = new AdapterProtocolRange { Min = 2, Max = 2 },
+ Properties = { new AdapterProperty { Name = "Folder", Required = true } },
+ });
+ return (package, folder);
+ }
+
+ [TestMethod]
+ public async Task A_receiver_runs_its_session_in_order_and_leaves_the_source_alone_unless_allowed()
+ {
+ var (package, folder) = Receiver();
+ var report = await RunAsync(package, new Dictionary { ["Folder"] = folder });
+
+ Assert.IsTrue(report.Passed, string.Join("; ", report.Checks.Where(c => c.Outcome == CheckOutcome.Failed)));
+ Assert.AreEqual(CheckOutcome.Passed, Check(report, "bitween receiver: ListFiles").Outcome);
+ Assert.AreEqual(CheckOutcome.Passed, Check(report, "bitween receiver: GetFile").Outcome);
+ Assert.AreEqual(CheckOutcome.Skipped, Check(report, "bitween receiver: DeleteFile").Outcome);
+ Assert.AreEqual(2, Directory.GetFiles(folder).Length, "nothing was deleted");
+ }
+
+ [TestMethod]
+ public async Task A_receiver_s_delete_runs_when_allowed()
+ {
+ var (package, folder) = Receiver();
+ var report = await RunAsync(package, new Dictionary { ["Folder"] = folder }, allowDelete: true);
+
+ Assert.AreEqual(CheckOutcome.Passed, Check(report, "bitween receiver: DeleteFile").Outcome);
+ CollectionAssert.AreEqual(new[] { "b.json" }, Directory.GetFiles(folder).Select(Path.GetFileName).ToArray(),
+ "the first file listed was deleted");
+ }
+
+ [TestMethod]
+ public async Task An_adapter_on_the_old_SDK_is_told_it_can_t_describe_itself()
+ {
+ var report = await RunAsync(Package("SW.Serverless.Compat.ClassicV10", new AdapterManifest { Id = "test.old" }));
+
+ var describe = Check(report, "describe");
+ Assert.AreEqual(CheckOutcome.Failed, describe.Outcome);
+ StringAssert.Contains(describe.Detail, "older than 10.1.0");
+ Assert.AreEqual(CheckOutcome.Passed, Check(report, "starts").Outcome, "it still runs");
+ }
+}
diff --git a/SW.Serverless.Installer.UnitTests/InstallerLogicTests.cs b/SW.Serverless.Installer.UnitTests/InstallerLogicTests.cs
index 470fdc2..382ab96 100644
--- a/SW.Serverless.Installer.UnitTests/InstallerLogicTests.cs
+++ b/SW.Serverless.Installer.UnitTests/InstallerLogicTests.cs
@@ -5,7 +5,7 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
-using SW.Serverless.Installer.Shared;
+using SW.Serverless.Tooling;
namespace SW.Serverless.Installer.UnitTests;
diff --git a/SW.Serverless.Installer.UnitTests/ObjectStore.cs b/SW.Serverless.Installer.UnitTests/ObjectStore.cs
index 346944f..b7e2336 100644
--- a/SW.Serverless.Installer.UnitTests/ObjectStore.cs
+++ b/SW.Serverless.Installer.UnitTests/ObjectStore.cs
@@ -28,7 +28,7 @@ record Item(byte[] Content, Dictionary Metadata);
public string Register()
{
var name = "objectstore-" + Guid.NewGuid().ToString("N");
- Shared.CloudFilesFactory.Register(name, _ => this);
+ SW.Serverless.Tooling.CloudFilesFactory.Register(name, _ => this);
return name;
}
diff --git a/SW.Serverless.Installer.UnitTests/SW.Serverless.Installer.UnitTests.csproj b/SW.Serverless.Installer.UnitTests/SW.Serverless.Installer.UnitTests.csproj
index d0ff5e5..74d418e 100644
--- a/SW.Serverless.Installer.UnitTests/SW.Serverless.Installer.UnitTests.csproj
+++ b/SW.Serverless.Installer.UnitTests/SW.Serverless.Installer.UnitTests.csproj
@@ -22,6 +22,12 @@
+
+
+
serverless init <name>
+ public class InitCliOptions
+ {
+ [Value(0, Required = true, MetaName = "name", HelpText = "The adapter's name, e.g. AcmeOrders: its folder, project and class.")]
+ public string Name { get; set; }
+
+ [Option("lang", Default = "dotnet", HelpText = "Its language: dotnet. Python, Node and Go arrive with their SDKs.")]
+ public string Language { get; set; }
+
+ [Option("kind", Default = "handler", HelpText = "handler, mapper, validator or receiver.")]
+ public string Kind { get; set; }
+
+ [Option("id", HelpText = "Its id in storage; derived from the name when not given (AcmeOrders -> acme.orders).")]
+ public string Id { get; set; }
+
+ [Option("dir", Default = ".", HelpText = "The folder to make it in.")]
+ public string Directory { get; set; }
+ }
+
+ /// serverless build [project]
+ public class BuildCliOptions
+ {
+ [Value(0, MetaName = "project", Default = ".", HelpText = "The adapter's project folder, with its adapter.json.")]
+ public string Project { get; set; }
+
+ [Option('o', "out", HelpText = "Where the package goes; bin/serverless in the project unless set.")]
+ public string Output { get; set; }
+
+ [Option("no-source", HelpText = "Leave the source out of the package.")]
+ public bool NoSource { get; set; }
+
+ [Option("allow", HelpText = "A source file whose secret-scan finding is a false positive. Repeat for more.")]
+ public IEnumerable Allow { get; set; }
+
+ [Option("dry-run", HelpText = "Show the source that would be carried, and build nothing.")]
+ public bool DryRun { get; set; }
+ }
+
+ /// serverless test [package]
+ public class TestCliOptions
+ {
+ [Value(0, MetaName = "package", Default = ".",
+ HelpText = "A package zip, a package folder, or a project folder (built first).")]
+ public string Package { get; set; }
+
+ [Option("settings", HelpText = "A JSON file of settings to run it with: { \"Name\": \"value\" }. The adapter calls whatever they point at.")]
+ public string Settings { get; set; }
+
+ [Option("allow-delete", HelpText = "Let a receiver's DeleteFile run: it removes or moves a real file at the source.")]
+ public bool AllowDelete { get; set; }
+
+ [Option("contract", HelpText = "A contract file to check against, beyond those the CLI carries. Repeat for more.")]
+ public IEnumerable Contracts { get; set; }
+
+ [Option("timeout", Default = 60, HelpText = "Seconds one call may take.")]
+ public int Timeout { get; set; }
+ }
+
+ /// serverless run [package] --call Command
+ public class RunCliOptions
+ {
+ [Value(0, MetaName = "package", Default = ".", HelpText = "A package zip, a package folder, or a project folder (built first).")]
+ public string Package { get; set; }
+
+ [Option("settings", HelpText = "A JSON file of settings to run it with.")]
+ public string Settings { get; set; }
+
+ [Option("call", Required = true, HelpText = "The command to call, e.g. Handle.")]
+ public string Command { get; set; }
+
+ [Option("input", HelpText = "Its argument: JSON, plain text, or @file to read it from a file.")]
+ public string Input { get; set; }
+
+ [Option("timeout", Default = 60, HelpText = "Seconds the call may take.")]
+ public int Timeout { get; set; }
+ }
+
+ /// serverless manifest validate [path]
+ public class ManifestCliOptions
+ {
+ [Value(0, Required = true, MetaName = "action", HelpText = "validate")]
+ public string Action { get; set; }
+
+ [Value(1, MetaName = "path", Default = ".", HelpText = "An adapter.json, or the folder holding one.")]
+ public string Path { get; set; }
+ }
+
+ /// serverless publish <package>
+ public class PublishPackageCliOptions : StorageCliOptions
+ {
+ [Value(0, Required = true, MetaName = "package", HelpText = "A package zip from serverless build.")]
+ public string Package { get; set; }
+
+ [Option('v', "version", HelpText = "The version: explicit, or major, minor or patch to bump. The manifest's version unless set.")]
+ public string Version { get; set; }
+
+ [Option("no-promote", HelpText = "Upload the version without making it the one that runs.")]
+ public bool NoPromote { get; set; }
+
+ [Option("notes", HelpText = "Release notes for this version (Markdown).")]
+ public string Notes { get; set; }
+
+ [Option("published-by", HelpText = "Who is publishing, for the catalog.")]
+ public string PublishedBy { get; set; }
+ }
+
+ /// The commands that work on adapters in any language, over SW.Serverless.Tooling.
+ static class AdapterCommands
+ {
+ const int Success = Program.Success;
+ const int Failure = Program.Failure;
+
+ public static Task Init(InitCliOptions opts)
+ {
+ var result = Scaffolder.Scaffold(new ScaffoldRequest
+ {
+ Name = opts.Name,
+ Id = opts.Id,
+ Language = opts.Language,
+ Kind = opts.Kind,
+ ParentDirectory = opts.Directory,
+ });
+ if (!result.Succeeded)
+ {
+ foreach (var problem in result.Problems) Console.WriteLine(problem);
+ return Task.FromResult(Failure);
+ }
+
+ Console.WriteLine($"Made a {opts.Kind} adapter in {result.ProjectDirectory}:");
+ foreach (var file in result.Files) Console.WriteLine($" {file}");
+ Console.WriteLine("Next: serverless build, then serverless test --settings settings.json");
+ return Task.FromResult(Success);
+ }
+
+ public static async Task Build(BuildCliOptions opts)
+ {
+ var result = await PackageBuilder.BuildAsync(new BuildRequest
+ {
+ ProjectDirectory = opts.Project,
+ OutputDirectory = opts.Output,
+ IncludeSource = !opts.NoSource,
+ AllowedFiles = new HashSet(opts.Allow ?? Enumerable.Empty(), StringComparer.OrdinalIgnoreCase),
+ DryRun = opts.DryRun,
+ Log = Console.WriteLine,
+ });
+
+ if (result.SourceFiles.Count > 0)
+ {
+ Console.WriteLine($"Source carried ({result.SourceFiles.Count} files, {result.SourceFiles.Sum(f => f.Bytes) / 1024} KB):");
+ foreach (var (path, bytes) in result.SourceFiles.OrderBy(f => f.Path, StringComparer.Ordinal))
+ Console.WriteLine($" {path} ({bytes} B)");
+ }
+ foreach (var warning in result.Warnings) Console.WriteLine($"Warning: {warning}");
+ foreach (var problem in result.Problems) Console.WriteLine(problem);
+ if (!result.Succeeded) return Failure;
+
+ if (!opts.DryRun) Console.WriteLine($"Built {result.ZipPath}");
+ return Success;
+ }
+
+ public static async Task Test(TestCliOptions opts)
+ {
+ var (package, cleanup) = await PackageFolderAsync(opts.Package);
+ if (package == null) return Failure;
+ try
+ {
+ var report = await new ConformanceRunner().RunAsync(new ConformanceOptions
+ {
+ PackageDirectory = package,
+ Settings = ReadSettings(opts.Settings),
+ AllowDelete = opts.AllowDelete,
+ Contracts = (opts.Contracts ?? Enumerable.Empty()).Select(ContractDocument.FromFile).ToList(),
+ CommandTimeoutSeconds = opts.Timeout,
+ Log = Console.WriteLine,
+ });
+
+ foreach (var check in report.Checks)
+ Console.WriteLine($"{Mark(check.Outcome)} {check.Name}{(string.IsNullOrEmpty(check.Detail) ? "" : " — " + check.Detail)}");
+ var failed = report.Checks.Count(c => c.Outcome == CheckOutcome.Failed);
+ Console.WriteLine(failed == 0 ? "Conforms." : $"{failed} check{(failed == 1 ? "" : "s")} failed.");
+ return report.Passed ? Success : Failure;
+ }
+ finally
+ {
+ cleanup();
+ }
+ }
+
+ public static async Task Run(RunCliOptions opts)
+ {
+ var (package, cleanup) = await PackageFolderAsync(opts.Package);
+ if (package == null) return Failure;
+ try
+ {
+ await using var host = await LocalAdapterHost.StartAsync(package, ReadSettings(opts.Settings), commandTimeoutSeconds: opts.Timeout);
+ Console.WriteLine(await host.CallAsync(opts.Command, ReadInput(opts.Input)));
+ return Success;
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ Console.WriteLine(ex.GetBaseException().Message);
+ return Failure;
+ }
+ finally
+ {
+ cleanup();
+ }
+ }
+
+ public static Task Manifest(ManifestCliOptions opts)
+ {
+ if (opts.Action != "validate")
+ {
+ Console.WriteLine($"'{opts.Action}' isn't a manifest action: use validate");
+ return Task.FromResult(Failure);
+ }
+
+ var path = Directory.Exists(opts.Path) ? System.IO.Path.Combine(opts.Path, AdapterManifest.FileName) : opts.Path;
+ if (!File.Exists(path))
+ {
+ Console.WriteLine($"There is no {path}");
+ return Task.FromResult(Failure);
+ }
+
+ try
+ {
+ var problems = AdapterManifest.Parse(File.ReadAllText(path)).Validate();
+ foreach (var problem in problems) Console.WriteLine(problem);
+ Console.WriteLine(problems.Count == 0 ? $"{path} is valid." : $"{problems.Count} problem{(problems.Count == 1 ? "" : "s")} in {path}.");
+ return Task.FromResult(problems.Count == 0 ? Success : Failure);
+ }
+ catch (JsonException ex)
+ {
+ Console.WriteLine($"{path} isn't valid JSON: {ex.Message}");
+ return Task.FromResult(Failure);
+ }
+ }
+
+ public static Task Publish(PublishPackageCliOptions opts, Func environment) =>
+ Program.WithStorage(opts, environment, async files =>
+ {
+ var result = await PackagePublisher.PublishPackageAsync(files, new PublishPackageRequest
+ {
+ PackagePath = opts.Package,
+ Version = opts.Version,
+ Promote = !opts.NoPromote,
+ ReleaseNotes = opts.Notes,
+ PublishedBy = UploadOptionsResolver.ResolvePublishedBy(opts.PublishedBy, environment),
+ }, Console.WriteLine);
+ Console.WriteLine($"Published {result.Manifest.Id} {result.Version}.");
+ });
+
+ static string Mark(CheckOutcome outcome) => outcome switch
+ {
+ CheckOutcome.Passed => "PASS",
+ CheckOutcome.Failed => "FAIL",
+ _ => "SKIP",
+ };
+
+ /// A package folder for whatever was given: a folder with adapter.json, a zip, or a project to build.
+ static async Task<(string Folder, Action Cleanup)> PackageFolderAsync(string given)
+ {
+ var path = System.IO.Path.GetFullPath(given);
+ if (File.Exists(path) && path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
+ {
+ var folder = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "swsl-cli", Guid.NewGuid().ToString("N"));
+ ZipFile.ExtractToDirectory(path, folder);
+ return (folder, () => TryDelete(folder));
+ }
+
+ if (Directory.Exists(path) && Directory.GetFiles(path, "*.*proj").Length > 0)
+ {
+ var output = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "swsl-cli", Guid.NewGuid().ToString("N"));
+ var built = await PackageBuilder.BuildAsync(new BuildRequest { ProjectDirectory = path, OutputDirectory = output, Log = Console.WriteLine });
+ foreach (var problem in built.Problems) Console.WriteLine(problem);
+ return built.Succeeded ? (built.PackageDirectory, () => TryDelete(output)) : (null, () => TryDelete(output));
+ }
+
+ if (Directory.Exists(path) && File.Exists(System.IO.Path.Combine(path, AdapterManifest.FileName)))
+ return (path, () => { });
+
+ Console.WriteLine($"{given} is neither a package zip, a package folder nor a project folder");
+ return (null, () => { });
+ }
+
+ static IDictionary ReadSettings(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path)) return new Dictionary();
+ using var document = JsonDocument.Parse(File.ReadAllText(path));
+ return document.RootElement.EnumerateObject().ToDictionary(p => p.Name,
+ p => p.Value.ValueKind == JsonValueKind.String ? p.Value.GetString() : p.Value.GetRawText());
+ }
+
+ static object ReadInput(string input)
+ {
+ if (input == null) return null;
+ if (input.StartsWith('@')) input = File.ReadAllText(input[1..]);
+ // JSON goes as JSON; anything else as the plain text a string argument is.
+ try { return Newtonsoft.Json.Linq.JToken.Parse(input); }
+ catch (Newtonsoft.Json.JsonReaderException) { return input; }
+ }
+
+ static void TryDelete(string folder)
+ {
+ try { Directory.Delete(folder, true); } catch { }
+ }
+ }
+}
diff --git a/SW.Serverless.Installer/Options.cs b/SW.Serverless.Installer/Options.cs
index 02e99dd..4f533de 100644
--- a/SW.Serverless.Installer/Options.cs
+++ b/SW.Serverless.Installer/Options.cs
@@ -103,48 +103,4 @@ public class WithdrawCliOptions : StorageCliOptions
public string Version { get; set; }
}
- public class ServerlessUploadOptions
- {
- public string Provider { get; set; }
-
- public string AccessKeyId { get; set; }
-
- public string SecretAccessKey { get; set; }
-
- public string BucketName { get; set; }
-
- public string ServiceUrl { get; set; }
-
-
- public string RSAKey { get; set; }
-
- public string UserId { get; set; }
-
- public string FingerPrint { get; set; }
-
- public string TenantId { get; set; }
-
- public string Region { get; set; }
-
-
- public string Version { get; set; }
-
-
- public string AdapterId { get; set; }
- public string NamespaceName { get; set; }
-
- // ——— Google Cloud service account ———
- public string ProjectId { get; set; }
- public string PrivateKeyId { get; set; }
- public string PrivateKey { get; set; }
- public string ClientEmail { get; set; }
- public string ClientId { get; set; }
- public string ClientX509CertUrl { get; set; }
-
- ///
- /// Roles this adapter serves, comma separated. Overrides what the assembly declared, for
- /// an adapter whose author has not added [AdapterKind] to it.
- ///
- public string Kind { get; set; }
- }
}
diff --git a/SW.Serverless.Installer/Program.cs b/SW.Serverless.Installer/Program.cs
index 95d4f48..cb31f36 100644
--- a/SW.Serverless.Installer/Program.cs
+++ b/SW.Serverless.Installer/Program.cs
@@ -1,7 +1,7 @@
using CommandLine;
using SW.PrimitiveTypes;
using SW.Serverless.Contract.Catalog;
-using SW.Serverless.Installer.Shared;
+using SW.Serverless.Tooling;
using System;
using System.Collections.Generic;
using System.IO;
@@ -16,7 +16,12 @@ public static class Program
public const int Failure = 1;
/// The commands besides publishing. Matched on the first argument only.
- static readonly HashSet Commands = new(StringComparer.Ordinal) { "promote", "versions", "withdraw" };
+ static readonly HashSet Commands = new(StringComparer.Ordinal)
+ {
+ "promote", "versions", "withdraw",
+ // For adapters in any language, over SW.Serverless.Tooling.
+ "init", "build", "test", "run", "manifest", "publish",
+ };
private static Task Main(string[] args) => RunAsync(args);
@@ -47,6 +52,13 @@ public static async Task RunAsync(string[] args, Func envir
o => Promote(o, environment)),
"versions" => await Run(parser.ParseArguments(rest),
o => Versions(o, environment)),
+ "init" => await Run(parser.ParseArguments(rest), AdapterCommands.Init),
+ "build" => await Run(parser.ParseArguments(rest), AdapterCommands.Build),
+ "test" => await Run(parser.ParseArguments(rest), AdapterCommands.Test),
+ "run" => await Run(parser.ParseArguments(rest), AdapterCommands.Run),
+ "manifest" => await Run(parser.ParseArguments(rest), AdapterCommands.Manifest),
+ "publish" => await Run(parser.ParseArguments(rest),
+ o => AdapterCommands.Publish(o, environment)),
_ => await Run(parser.ParseArguments(rest),
o => Withdraw(o, environment)),
};
@@ -221,6 +233,33 @@ static async Task WithRepository(StorageCliOptions opts, string adapterId,
}
}
+ /// Runs against the storage the options name, reporting a failure as a message.
+ internal static async Task WithStorage(StorageCliOptions opts, Func environment,
+ Func action)
+ {
+ try
+ {
+ await action(CloudFilesFactory.Create(await GetServerlessUploadOptions(opts, environment)));
+ return Success;
+ }
+ catch (SWException ex)
+ {
+ Console.WriteLine(ex.Message);
+ return Failure;
+ }
+ catch (ArgumentException ex)
+ {
+ // Semver's verdicts: an existing or lower version, or an unknown bump.
+ Console.WriteLine(ex.Message);
+ return Failure;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine(ex.ToString());
+ return Failure;
+ }
+ }
+
public static IEnumerable FormatVersions(string adapterId, VersionListing listing)
{
if (listing.Versions.Count == 0)
diff --git a/SW.Serverless.Installer/SW.Serverless.Installer.csproj b/SW.Serverless.Installer/SW.Serverless.Installer.csproj
index 5ba43b1..3f95b76 100644
--- a/SW.Serverless.Installer/SW.Serverless.Installer.csproj
+++ b/SW.Serverless.Installer/SW.Serverless.Installer.csproj
@@ -9,16 +9,6 @@
-
-
-
-
-
-
-
-
-
-
@@ -26,6 +16,8 @@
+
+
diff --git a/SW.Serverless.Sdk/AdapterContractAttribute.cs b/SW.Serverless.Sdk/AdapterContractAttribute.cs
new file mode 100644
index 0000000..c5b729b
--- /dev/null
+++ b/SW.Serverless.Sdk/AdapterContractAttribute.cs
@@ -0,0 +1,21 @@
+using System;
+
+namespace SW.Serverless.Sdk
+{
+ ///
+ /// A contract this adapter implements, and its version — for Bitween, "bitween" 1. Reported
+ /// in the adapter's description and handshake, and checked by serverless test.
+ ///
+ [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
+ public class AdapterContractAttribute : Attribute
+ {
+ public AdapterContractAttribute(string name, int version)
+ {
+ Name = name;
+ Version = version;
+ }
+
+ public string Name { get; }
+ public int Version { get; }
+ }
+}
diff --git a/SW.Serverless.Sdk/Constants.cs b/SW.Serverless.Sdk/Constants.cs
index 2a51eb5..8994cdc 100644
--- a/SW.Serverless.Sdk/Constants.cs
+++ b/SW.Serverless.Sdk/Constants.cs
@@ -14,5 +14,16 @@ public static class Constants
public const string LogErrorIdentifier = "{{log.error}}";
public const string CorrelationIdName = "CorrelationId";
+ ///
+ /// Passed instead of the three base64 arguments when the host sends them on stdin: the
+ /// options, the startup values and the adapter values, one line each, before the first
+ /// command. Startup values hold passwords and keys, and anything on the command line can
+ /// be read by every process on the machine.
+ ///
+ public const string ValuesOnStdinFlag = "--values-on-stdin";
+
+ /// The first SDK release that reads .
+ public const string ValuesOnStdinSince = "10.1.0";
+
}
}
diff --git a/SW.Serverless.Sdk/Describer.cs b/SW.Serverless.Sdk/Describer.cs
new file mode 100644
index 0000000..8cf889e
--- /dev/null
+++ b/SW.Serverless.Sdk/Describer.cs
@@ -0,0 +1,130 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Schema;
+using System.Threading.Tasks;
+using SW.Serverless.Contract.Catalog;
+
+namespace SW.Serverless.Sdk
+{
+ ///
+ /// Answers --describe: the adapter's settings, commands, kinds and contracts as an
+ /// on stdout, without connecting to any host.
+ ///
+ internal static class Describer
+ {
+ public static bool Requested(string[] args) =>
+ args.Length > 1 && args[1] == AdapterSelfDescription.Flag;
+
+ // Payloads travel as Newtonsoft writes them — property names as declared — so the schema
+ // names them the same way.
+ static readonly JsonSerializerOptions SchemaOptions = new(JsonSerializerDefaults.General)
+ {
+ TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver(),
+ };
+
+ ///
+ /// Builds the handler, which is what runs its Runner.Expect declarations. Without its
+ /// settings a handler may refuse to be built; the description is then completed without
+ /// them and says why.
+ ///
+ public static AdapterSelfDescription Describe(Type handlerType, Func