Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/instructions/dotnet.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ the lightest option:
- **File-scoped namespaces** in new files.
- Match the style of the file you're editing.
- No unused `using` directives.
- Use `var` only when the type is obvious from the right-hand side (e.g.
`new T(...)`, a cast, or a literal). When the type is hidden behind a method
or property call, write the type explicitly so readers don't have to chase
the API to know what they're holding.
- Don't run `dotnet format` across unrelated files; no drive-by reformatting.
- Don't disable analyzers to silence warnings, fix the underlying issue.

Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ the full CLI.
changes.
- Follow the existing `stylecop.json` / analyzer rules. Don't disable analyzers
to silence warnings, fix the underlying issue.
- Use `var` only when the type is obvious from the right-hand side (e.g.
`new T(...)`, a cast, or a literal). When the type is hidden behind a method
or property call, write the type explicitly so readers don't have to chase
the API to know what they're holding.
- Keep changes minimal and surgical. User-visible behavior changes must be
flagged explicitly in the PR description.

Expand Down
4 changes: 2 additions & 2 deletions docs/cli-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ Build commands (Parser.CreateCommand):
│ built-ins or with each other are skipped with a warning that names the
│ workload(s)
├── InitCommand sees IEnumerable<IProjectInitializer>, attaches their options
├── WorkloadListCommand sees IReadOnlyList<WorkloadInfo>
├── WorkloadListCommand depends on IWorkloadProvider
└── HelpCommand built last with a back-reference to the constructed root

Invoke (when the user runs a command):
Expand All @@ -254,7 +254,7 @@ Invoke (when the user runs a command):

### Empty State (Today)

Until the loader lands, `WorkloadRegistration.RegisterWorkloads` registers an empty `IReadOnlyList<WorkloadInfo>`. With no workloads installed:
Until any workload SDK lands, no `workloads.json` exists in `~/.azure-functions/`, so `IWorkloadProvider.GetWorkloadsAsync` resolves to an empty list. With no workloads installed:

- `func workload list` prints `No workloads installed.`
- `func init` prints "No stacks installed." and a hint to install one (exit code 1).
Expand Down
27 changes: 27 additions & 0 deletions src/Abstractions/Workloads/EntryPointSpec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

namespace Azure.Functions.Cli.Workloads;

/// <summary>
/// Identifies the type that implements <see cref="Workload"/>. Shared between
/// the per-workload <see cref="WorkloadMetadata"/> (in the package root) and
/// the CLI's global workload registry, so a workload's entry-point is
/// described the same way at author time and at install time.
/// </summary>
public sealed class EntryPointSpec
{
/// <summary>
/// Path to the assembly relative to the package's content root
/// (<c>tools/any/</c> by convention), e.g. <c>Foo.dll</c>.
/// </summary>
public required string AssemblyPath { get; init; }

/// <summary>
/// Fully-qualified type name implementing <see cref="Workload"/>. Stored
/// as a string so the metadata stays loadable even when the assembly
/// isn't on the runtime probe path (e.g. listing workloads without
/// loading them).
/// </summary>
public required string Type { get; init; }
}
2 changes: 1 addition & 1 deletion src/Abstractions/Workloads/FunctionsCliBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
namespace Azure.Functions.Cli.Workloads;

/// <summary>
/// Bootstrap surface passed to <see cref="IWorkload.Configure"/>.
/// Bootstrap surface passed to <see cref="Workload.Configure"/>.
/// Workloads register their services — project initializers, commands, and
/// any other supporting types — through <see cref="Services"/> and the
/// <see cref="RegisterCommand(FuncCommand)"/> overloads.
Expand Down
44 changes: 0 additions & 44 deletions src/Abstractions/Workloads/IWorkload.cs

This file was deleted.

83 changes: 83 additions & 0 deletions src/Abstractions/Workloads/Workload.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System.Reflection;

namespace Azure.Functions.Cli.Workloads;

/// <summary>
/// Base class for a workload's entry-point. The CLI loader instantiates the
/// type identified by the workload's <see cref="WorkloadMetadata"/>
/// (<c>workload.json</c> in the package root), then invokes
/// <see cref="Configure"/> so the workload can register its services with
/// <see cref="FunctionsCliBuilder"/>.
///
/// Mirrors the shape of WebJobs' <c>IWebJobsStartup</c>. Implementations must
/// have a parameterless constructor.
///
/// Abstract class (rather than an interface) so we can grow the surface with
/// new properties or virtual members without breaking existing workloads.
/// </summary>
public abstract class Workload
{
/// <summary>
/// Globally unique workload identifier, typically the assembly / NuGet
/// package name (e.g. <c>"Azure.Functions.Cli.Workload.Dotnet"</c>).
/// </summary>
public abstract string Name { get; }

/// <summary>
/// Workload version. Defaults to the workload assembly's
/// <see cref="System.Reflection.AssemblyInformationalVersionAttribute"/>
/// (falling back to <see cref="System.Reflection.AssemblyFileVersionAttribute"/>
/// and then <see cref="System.Reflection.AssemblyName.Version"/>), so
/// most workloads can leave this alone and let the build supply the
/// version. Override to author the version on the workload itself when
/// the running code should be the source of truth. Should be a valid
/// SemVer 2.0 string (e.g. <c>"1.2.3"</c>, <c>"1.2.3-preview.1"</c>);
/// the CLI does not currently enforce or normalize the format.
/// </summary>
public virtual string Version
{
get
{
Assembly assembly = GetType().Assembly;

string? informational = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(informational))
{
// Strip any +sha build metadata so callers see a clean SemVer string.
int plus = informational.IndexOf('+');
return plus >= 0 ? informational[..plus] : informational;
}

string? file = assembly
.GetCustomAttribute<AssemblyFileVersionAttribute>()
?.Version;
if (!string.IsNullOrWhiteSpace(file))
{
return file;
}

return assembly.GetName().Version?.ToString() ?? "0.0.0";
}
}

/// <summary>
/// Human-readable name shown in <c>func workload list</c>.
/// </summary>
public abstract string DisplayName { get; }

/// <summary>
/// One-line description of what the workload provides.
/// </summary>
public abstract string Description { get; }

/// <summary>
/// Registers the workload's services with the host. Called once during
/// CLI bootstrap, before the root command tree is built.
/// </summary>
public abstract void Configure(FunctionsCliBuilder builder);
}
24 changes: 24 additions & 0 deletions src/Abstractions/Workloads/WorkloadMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

namespace Azure.Functions.Cli.Workloads;

/// <summary>
/// Per-workload manifest authored alongside the <see cref="Workload"/>
/// implementation and shipped at the root of the workload's NuGet package
/// (<c>workload.json</c>). Required: a package without this file is not a
/// valid workload.
///
/// Read at install time to discover the entry-point assembly and type, which
/// the CLI then records in its global workload registry. Distinct from the
/// global registry itself: this file describes a single workload as authored;
/// the registry tracks every workload installed on the machine.
/// </summary>
public sealed class WorkloadMetadata
{
/// <summary>
/// Where to find the <see cref="Workload"/> implementation inside the
/// package. Path is relative to the package root.
/// </summary>
public required EntryPointSpec EntryPoint { get; init; }
}
28 changes: 16 additions & 12 deletions src/Func/Commands/Workload/WorkloadListCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,44 @@

using System.CommandLine;
using Azure.Functions.Cli.Console;
using Azure.Functions.Cli.Workloads.Storage;
using Azure.Functions.Cli.Workloads;

namespace Azure.Functions.Cli.Commands.Workload;

/// <summary>
/// Lists workloads recorded in the global manifest. Reads directly from
/// <see cref="IGlobalManifestStore"/> so listing works without invoking the
/// workload loader (no assembly loads, no ALC creation).
/// Lists workloads recorded in the global registry. Reads from the cached
/// <see cref="WorkloadInfo"/> list materialized by
/// <see cref="IWorkloadProvider"/> so display name and description come from
/// each loaded <see cref="Workloads.Workload"/> instance and the file-backed
/// registry isn't re-read on every invocation.
/// </summary>
internal sealed class WorkloadListCommand(IInteractionService interaction, IGlobalManifestStore store)
internal sealed class WorkloadListCommand(
IInteractionService interaction,
IWorkloadProvider workloads)
: FuncCliCommand("list", "List installed workloads.")
{
private const string AliasesPlaceholder = "-";

private readonly IInteractionService _interaction = interaction ?? throw new ArgumentNullException(nameof(interaction));
private readonly IGlobalManifestStore _store = store ?? throw new ArgumentNullException(nameof(store));
private readonly IWorkloadProvider _workloads = workloads ?? throw new ArgumentNullException(nameof(workloads));

protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
var workloads = await _store.GetWorkloadsAsync(cancellationToken).ConfigureAwait(false);
IReadOnlyList<WorkloadInfo> workloads = await _workloads.GetWorkloadsAsync(cancellationToken);

if (workloads.Count == 0)
{
_interaction.WriteHint("No workloads installed.");
return 0;
}

var rows = workloads.Select(w => new[]
IEnumerable<string[]> rows = workloads.Select(w => new[]
{
w.PackageId,
w.Entry.Aliases.Count == 0 ? AliasesPlaceholder : string.Join(", ", w.Entry.Aliases),
w.Entry.DisplayName,
w.Entry.Description,
w.Version,
w.Aliases.Count == 0 ? AliasesPlaceholder : string.Join(", ", w.Aliases),
w.Instance.DisplayName,
w.Instance.Description,
w.PackageVersion,
});

_interaction.WriteTable(["Package", "Aliases", "Name", "Description", "Version"], rows);
Expand Down
4 changes: 2 additions & 2 deletions src/Func/Hosting/DefaultFunctionsCliBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace Azure.Functions.Cli.Hosting;
/// Default <see cref="FunctionsCliBuilder"/> implementation. Internal so
/// workloads only see the abstract base type.
///
/// Each invocation of <see cref="IWorkload.Configure"/> gets its own instance
/// Each invocation of <see cref="Workload.Configure"/> gets its own instance
/// constructed with the corresponding <see cref="WorkloadInfo"/>; the
/// underlying <see cref="IServiceCollection"/> is the same global container
/// the host uses, so workloads contributing the same service interface all
Expand Down Expand Up @@ -58,7 +58,7 @@ protected override void OnRegisterCommand(Func<IServiceProvider, FuncCommand> fa
private WorkloadInfo RequireWorkload()
=> _workload ?? throw new InvalidOperationException(
"Commands can only be registered through a workload-scoped builder. " +
"RegisterCommand is invoked by the workload loader during IWorkload.Configure; " +
"RegisterCommand is invoked by the workload loader during Workload.Configure; " +
"calling it on the host's global builder is a CLI bug.");
}

14 changes: 8 additions & 6 deletions src/Func/Hosting/WorkloadRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ namespace Azure.Functions.Cli.Hosting;
/// each workload's entry-point assembly into its own
/// <see cref="System.Runtime.Loader.AssemblyLoadContext"/>, instantiates the
/// type identified by <c>[assembly: ExportCliWorkload&lt;T&gt;]</c>, and invokes
/// <see cref="IWorkload.Configure"/>.
/// <see cref="Workload.Configure"/>.
///
/// At this stage the loader hasn't landed yet, so <see cref="RegisterWorkloads"/>
/// is a no-op. Commands that only need to enumerate installed workloads
/// (e.g. <c>func workload list</c>) read the global manifest directly via
/// <see cref="Workloads.Storage.IGlobalManifestStore"/>; they don't need the
/// loader at all.
/// At this stage <see cref="RegisterWorkloads"/> is a no-op: the loaded-
/// workloads list itself is published as a singleton by
/// <see cref="WorkloadStorageRegistration.AddWorkloadStorage"/>, so commands
/// that only need to enumerate installed workloads (e.g. <c>func workload
/// list</c>) inject <c>IReadOnlyList&lt;WorkloadInfo&gt;</c> directly. This
/// hook will grow to invoke <see cref="Workload.Configure"/> per loaded
/// workload once the contribution surface lands.
/// </summary>
internal static class WorkloadRegistration
{
Expand Down
26 changes: 20 additions & 6 deletions src/Func/Hosting/WorkloadStorageRegistration.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using Azure.Functions.Cli.Workloads;
using Azure.Functions.Cli.Workloads.Discovery;
using Azure.Functions.Cli.Workloads.Loading;
using Azure.Functions.Cli.Workloads.Storage;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace Azure.Functions.Cli.Hosting;

/// <summary>
/// Wires the workload-storage subsystem (paths + manifest store) into DI.
/// Binds <see cref="WorkloadPathsOptions"/> from the <c>Workloads</c>
/// configuration section so the <c>FUNC_CLI_Workloads__Home</c> env var
/// (registered at host build) flows through, while tests can register their
/// own options without touching process-global state.
/// Wires the workload-storage subsystem (paths + manifest store + loader +
/// provider) into DI. Binds <see cref="WorkloadPathsOptions"/> from the
/// <c>Workloads</c> configuration section so the
/// <c>FUNC_CLI_Workloads__Home</c> env var (registered at host build) flows
/// through, while tests can register their own options without touching
/// process-global state.
/// </summary>
internal static class WorkloadStorageRegistration
{
Expand All @@ -35,7 +39,17 @@ public static IServiceCollection AddWorkloadStorage(this IServiceCollection serv
// IOptions<> themselves.
services.AddSingleton<IWorkloadPaths>(
sp => sp.GetRequiredService<IOptions<WorkloadPathsOptions>>().Value);
services.AddSingleton<IGlobalManifestStore, GlobalManifestStore>();
services.AddSingleton<IWorkloadStore, WorkloadStore>();
services.AddSingleton<IWorkloadLoader, WorkloadLoader>();
services.AddSingleton<IWorkloadMetadataReader, WorkloadMetadataReader>();

// The provider composes store + loader, lazily materializes the
// installed-workloads list, and caches it for the life of the
// process. Commands that need to enumerate live workloads (list,
// alias routing, command contribution) inject this rather than the
// store + loader directly. Singleton because the cache must be shared
// across resolutions.
services.AddSingleton<IWorkloadProvider, WorkloadProvider>();

return services;
}
Expand Down
Loading
Loading