Skip to content
Open
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: 2 additions & 2 deletions docs/building-a-workload.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This guide walks through building a workload for the Azure Functions Core Tools v5 CLI. A workload is a NuGet package that the CLI loads at runtime to extend its behavior — most commonly to provide `func init` / `func new` support for a specific language stack (e.g. Node.js, Python, Java), but a workload can also contribute brand-new subcommands.

> **Status**: the abstractions and DI host described below are in the tree as of this PR. The runtime loader and `func workload install` / `uninstall` commands land in a follow-up PR; until then, this document describes the contract workload authors can build against.
> **Status**: the abstractions, DI host, and `func workload install` / `uninstall` commands described below are in the tree as of this PR. Workloads are installed from a local `.nupkg` on disk; NuGet feed acquisition lands in a follow-up.

## Architecture

Expand Down Expand Up @@ -58,7 +58,7 @@ The shape mirrors WebJobs' `IWebJobsStartup` — `Configure(FunctionsCliBuilder)
2. Reference `Azure.Functions.Cli.Abstractions`
3. Implement `IWorkload`
4. Inside `Configure`, register an `IProjectInitializer` (and/or contribute top-level commands via `builder.RegisterCommand(...)`, and any supporting services)
5. Build, package, and (once the loader ships) install with `func workload install`
5. Build, package, and install with `func workload install <path-to-nupkg>`

## Step 1: Create the Project

Expand Down
2 changes: 1 addition & 1 deletion docs/cli-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This document explains the runtime architecture of the Azure Functions Core Tools v5 CLI — how commands are composed, how telemetry is wired, and how the console layer works.

> **Status**: v5 is in active development. As of this PR, the workload abstractions and the DI host that loads workloads are wired into the CLI; the runtime workload loader and `func workload install` / `uninstall` commands land in a follow-up PR. Until then, the CLI runs with zero installed workloads — built-in commands that depend on workload contributions (e.g. `func init`) report "no workloads installed" and exit cleanly.
> **Status**: v5 is in active development. As of this PR, the workload abstractions, the DI host that loads workloads, and the `func workload install` / `uninstall` commands are wired into the CLI. Workloads are installed from a local `.nupkg` on disk; NuGet feed acquisition lands in a follow-up. Built-in commands that depend on workload contributions (e.g. `func init`) report "no workloads installed" and exit cleanly until at least one workload is installed.

## Startup Flow

Expand Down
2 changes: 1 addition & 1 deletion docs/repo-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This document describes the Azure Functions Core Tools v5 codebase — projects,

## Overview

The v5 CLI is built with [System.CommandLine](https://github.com/dotnet/command-line-api) and [Spectre.Console](https://spectreconsole.net/), targeting **.NET 10**. It follows a **workload model** (similar to the `dotnet` CLI) where the base CLI provides core commands and infrastructure while language-specific functionality (templates, init, pack) is delivered via independently installable workload packages. The base CLI, the host that loads workloads, and the public abstractions library are in the tree today; the runtime workload loader and `func workload install` / `uninstall` commands land in a follow-up PR.
The v5 CLI is built with [System.CommandLine](https://github.com/dotnet/command-line-api) and [Spectre.Console](https://spectreconsole.net/), targeting **.NET 10**. It follows a **workload model** (similar to the `dotnet` CLI) where the base CLI provides core commands and infrastructure while language-specific functionality (templates, init, pack) is delivered via independently installable workload packages. The base CLI, the host that loads workloads, the public abstractions library, and the `func workload install` / `uninstall` commands are all in the tree today; NuGet feed-based acquisition lands in a follow-up PR.

## Project Layout

Expand Down
1 change: 1 addition & 0 deletions eng/build/Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
<PackageVersion Include="NuGet.Packaging" Version="6.13.2" />
<PackageVersion Include="System.CommandLine" Version="2.0.6" />
<PackageVersion Include="Spectre.Console" Version="0.49.1" />
</ItemGroup>
Expand Down
15 changes: 11 additions & 4 deletions src/Func/Commands/Workload/WorkloadCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,25 @@ namespace Azure.Functions.Cli.Commands.Workload;

/// <summary>
/// Parent <c>func workload</c> command. Subcommands manage workload installation,
/// inspection, and updates. Today the only subcommand wired in is
/// <see cref="WorkloadListCommand"/>; install / uninstall land in a follow-up PR.
/// inspection, and updates.
///
/// Parent-only relies on <see cref="FuncCliCommand.ExecuteAsync"/>'s default
/// Parent-only: relies on <see cref="FuncCliCommand.ExecuteAsync"/>'s default
/// implementation to render help when invoked without a subcommand.
/// </summary>
internal sealed class WorkloadCommand : FuncCliCommand, IBuiltInCommand
{
public WorkloadCommand(WorkloadListCommand listCommand)
public WorkloadCommand(
WorkloadListCommand listCommand,
WorkloadInstallCommand installCommand,
WorkloadUninstallCommand uninstallCommand)
: base("workload", "Manage Func CLI workloads.")
{
ArgumentNullException.ThrowIfNull(listCommand);
ArgumentNullException.ThrowIfNull(installCommand);
ArgumentNullException.ThrowIfNull(uninstallCommand);

Subcommands.Add(listCommand);
Subcommands.Add(installCommand);
Subcommands.Add(uninstallCommand);
}
}
59 changes: 59 additions & 0 deletions src/Func/Commands/Workload/WorkloadInstallCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System.CommandLine;
using Azure.Functions.Cli.Common;
using Azure.Functions.Cli.Console;
using Azure.Functions.Cli.Workloads.Install;

namespace Azure.Functions.Cli.Commands.Workload;

/// <summary>
/// <c>func workload install &lt;package&gt;</c>. Installs a workload from a
/// <c>.nupkg</c> on disk. Feed-based acquisition (alias or package id
/// resolved against a NuGet feed) lands in a follow-up; the argument is
/// shaped to accept either form so the command surface doesn't have to
/// change when feeds are added.
/// </summary>
internal sealed class WorkloadInstallCommand : FuncCliCommand
{
private const string NupkgExtension = ".nupkg";

private readonly IInteractionService _interaction;
private readonly IWorkloadInstaller _installer;

public Argument<string> PackageArgument { get; } = new("package")
{
Description = "Workload to install. Currently must be a path to a .nupkg on disk.",
};

public WorkloadInstallCommand(IInteractionService interaction, IWorkloadInstaller installer)
: base("install", "Install a workload.")
{
_interaction = interaction ?? throw new ArgumentNullException(nameof(interaction));
_installer = installer ?? throw new ArgumentNullException(nameof(installer));

Arguments.Add(PackageArgument);
}

protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
string package = parseResult.GetValue(PackageArgument)
?? throw new GracefulException("package is required.", isUserError: true);

bool isNupkgPath = package.EndsWith(NupkgExtension, StringComparison.OrdinalIgnoreCase);
if (!isNupkgPath)
{
throw new GracefulException(
$"Resolving '{package}' against a NuGet feed is not yet supported. " +
"Pass a path to a .nupkg on disk.",
isUserError: true);
}

var installed = await _installer.InstallFromPackageAsync(package, cancellationToken);

_interaction.WriteSuccess(
$"Installed workload '{installed.PackageId}' version '{installed.PackageVersion}'.");
return 0;
}
}
136 changes: 136 additions & 0 deletions src/Func/Commands/Workload/WorkloadUninstallCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

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

namespace Azure.Functions.Cli.Commands.Workload;

/// <summary>
/// <c>func workload uninstall &lt;packageId&gt; [--version &lt;v&gt;] [--all-versions]</c>.
/// Removes one or all installed versions of a workload. Resolution rules:
/// <list type="bullet">
/// <item><description><c>--all-versions</c> removes every installed version.</description></item>
/// <item><description><c>--version</c> removes that exact version.</description></item>
/// <item><description>Neither flag: succeeds when exactly one version is installed; errors otherwise.</description></item>
/// </list>
/// </summary>
internal sealed class WorkloadUninstallCommand : FuncCliCommand
{
private readonly IInteractionService _interaction;
private readonly IWorkloadInstaller _installer;
private readonly IWorkloadStore _store;

public Argument<string> PackageArgument { get; } = new("package")
{
Description = "Package ID of the workload to uninstall.",
};

public Option<string?> VersionOption { get; } = new("--version", "-v")
{
Description = "Specific version to uninstall. Omit when only one version is installed.",
};

public Option<bool> AllVersionsOption { get; } = new("--all-versions", "-a")
{
Description = "Uninstall every installed version of the package.",
};

public WorkloadUninstallCommand(
IInteractionService interaction,
IWorkloadInstaller installer,
IWorkloadStore store)
: base("uninstall", "Uninstall a workload.")
{
_interaction = interaction ?? throw new ArgumentNullException(nameof(interaction));
_installer = installer ?? throw new ArgumentNullException(nameof(installer));
_store = store ?? throw new ArgumentNullException(nameof(store));

Arguments.Add(PackageArgument);
Options.Add(VersionOption);
Options.Add(AllVersionsOption);
}

protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
{
string packageId = parseResult.GetValue(PackageArgument)
?? throw new GracefulException("package is required.", isUserError: true);
string? version = parseResult.GetValue(VersionOption);
bool all = parseResult.GetValue(AllVersionsOption);

if (all && !string.IsNullOrEmpty(version))
{
throw new GracefulException(
"--all-versions and --version cannot be combined.",
isUserError: true);
}

IReadOnlyList<WorkloadEntry> installed = await _store.GetWorkloadsAsync(cancellationToken);
List<WorkloadEntry> matches = installed
.Where(w => string.Equals(w.PackageId, packageId, StringComparison.OrdinalIgnoreCase))
.ToList();

if (matches.Count == 0)
{
throw new GracefulException(
$"Workload '{packageId}' is not installed.",
isUserError: true);
}

IReadOnlyList<WorkloadEntry> toRemove = ResolveVersionsToRemove(packageId, version, all, matches);

foreach (WorkloadEntry candidate in toRemove)
{
bool removed = await _installer.UninstallAsync(candidate.PackageId, candidate.PackageVersion, cancellationToken);
if (removed)
{
_interaction.WriteSuccess(
$"Uninstalled workload '{candidate.PackageId}' version '{candidate.PackageVersion}'.");
}
}

return 0;
}

private static IReadOnlyList<WorkloadEntry> ResolveVersionsToRemove(
string packageId,
string? version,
bool all,
IReadOnlyList<WorkloadEntry> matches)
{
if (all)
{
return matches;
}

if (!string.IsNullOrEmpty(version))
{
WorkloadEntry? match = matches.FirstOrDefault(
m => string.Equals(m.PackageVersion, version, StringComparison.Ordinal));
if (match is null)
{
string available = string.Join(", ", matches.Select(m => m.PackageVersion));
throw new GracefulException(
$"Workload '{packageId}' version '{version}' is not installed. " +
$"Installed versions: {available}.",
isUserError: true);
}

return [match];
}

if (matches.Count > 1)
{
string available = string.Join(", ", matches.Select(m => m.PackageVersion));
throw new GracefulException(
$"Multiple versions of '{packageId}' are installed ({available}). " +
"Pass --version <v> or --all-versions.",
isUserError: true);
}

return matches;
}
}
1 change: 1 addition & 0 deletions src/Func/Func.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="NuGet.Packaging" />
<PackageReference Include="System.CommandLine" />
<PackageReference Include="Spectre.Console" />
<PackageReference Include="OpenTelemetry" />
Expand Down
2 changes: 2 additions & 0 deletions src/Func/Hosting/BuiltInCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public static IServiceCollection AddBuiltInCommands(this IServiceCollection serv
// the list command as its own concrete type (not as FuncCliCommand) so
// it doesn't get added at the top level by GetServices<FuncCliCommand>().
services.AddSingleton<WorkloadListCommand>();
services.AddSingleton<WorkloadInstallCommand>();
services.AddSingleton<WorkloadUninstallCommand>();
services.AddSingleton<FuncCliCommand, WorkloadCommand>();

return services;
Expand Down
26 changes: 26 additions & 0 deletions src/Func/Hosting/WorkloadInstallRegistration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// 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.Install;
using Microsoft.Extensions.DependencyInjection;

namespace Azure.Functions.Cli.Hosting;

/// <summary>
/// Wires the install pipeline (<see cref="IWorkloadInstaller"/>) into DI.
/// Depends on <see cref="WorkloadStorageRegistration.AddWorkloadStorage"/>
/// being registered first since the installer pulls
/// <c>IWorkloadPaths</c>, <c>IWorkloadStore</c>, and
/// <c>IWorkloadMetadataReader</c> from there.
/// </summary>
internal static class WorkloadInstallRegistration
{
public static IServiceCollection AddWorkloadInstaller(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);

services.AddSingleton<IWorkloadInstaller, WorkloadInstaller>();

return services;
}
}
1 change: 1 addition & 0 deletions src/Func/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
// FUNC_CLI_ prefix is stripped, "__" maps to section nesting).
hostBuilder.Configuration.AddEnvironmentVariables(prefix: Constants.EnvironmentVariablePrefix);
hostBuilder.Services.AddWorkloadStorage();
hostBuilder.Services.AddWorkloadInstaller();

// Let installed workloads contribute services. The builder exposes the same
// IServiceCollection the host uses, so anything a workload registers is
Expand Down
39 changes: 39 additions & 0 deletions src/Func/Workloads/Install/IWorkloadInstaller.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// 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.Storage;

namespace Azure.Functions.Cli.Workloads.Install;

/// <summary>
/// Coordinates installing and uninstalling workloads on disk + in the global
/// workload registry. Behind an interface so the install / uninstall commands
/// stay thin shells that don't deal with filesystem state directly.
/// </summary>
internal interface IWorkloadInstaller
{
/// <summary>
/// Installs a workload from the <c>.nupkg</c> at <paramref name="nupkgPath"/>.
/// The package is read in place, extracted into
/// <see cref="IWorkloadPaths.GetInstallDirectory"/>, and recorded in the
/// global registry. The source <c>.nupkg</c> file is left untouched.
/// </summary>
/// <exception cref="Common.GracefulException">
/// Thrown for: missing or unreadable <c>.nupkg</c>; missing / malformed
/// <c>workload.json</c>; the install directory already existing.
/// </exception>
public Task<WorkloadEntry> InstallFromPackageAsync(
string nupkgPath,
CancellationToken cancellationToken = default);

/// <summary>
/// Removes the install directory for (<paramref name="packageId"/>,
/// <paramref name="version"/>) and drops the registry entry. Returns
/// <c>true</c> when an entry was removed, <c>false</c> when no such
/// entry existed.
/// </summary>
public Task<bool> UninstallAsync(
string packageId,
string version,
CancellationToken cancellationToken = default);
}
Loading
Loading