Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ bin/$(CODEGEN): .make/prebuild .make/provider_mod_download provider/cmd/$(CODEGE
# Writes schema-full.json and metadata-compact.json to bin/
# Also re-calculates files in versions/ at same time
bin/schema-full.json bin/metadata-compact.json &: bin/$(CODEGEN) $(SPECS) versions/az-provider-list.json versions/v${PREVIOUS_MAJOR_VERSION}.yaml versions/v${MAJOR_VERSION}-config.yaml versions/v${MAJOR_VERSION}-spec.yaml versions/v${MAJOR_VERSION}-removed.json versions/v${MAJOR_VERSION}-removed-resources.json versions/v${NEXT_MAJOR_VERSION}-removed-resources.json
bin/$(CODEGEN) defaultVersions
bin/$(CODEGEN) schema

# Docs schema - treat as phony becasuse it's committed so we always need to rebuild it.
Expand Down
18 changes: 17 additions & 1 deletion provider/cmd/pulumi-gen-azure-native/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

"github.com/pulumi/pulumi-azure-native/v2/provider/pkg/debug"
"github.com/pulumi/pulumi-azure-native/v2/provider/pkg/gen"
"github.com/pulumi/pulumi-azure-native/v2/provider/pkg/openapi"
"github.com/pulumi/pulumi-azure-native/v2/provider/pkg/resources"
"github.com/pulumi/pulumi-azure-native/v2/provider/pkg/squeeze"
embeddedVersion "github.com/pulumi/pulumi-azure-native/v2/provider/pkg/version"
Expand Down Expand Up @@ -71,9 +72,10 @@ func main() {
codegenSchemaOutputPath := os.Getenv("CODEGEN_SCHEMA_OUTPUT_PATH")
codegenMetadataOutputPath := os.Getenv("CODEGEN_METADATA_OUTPUT_PATH")

specsDir := os.Getenv("CODEGEN_SPECS_DIR")
buildSchemaArgs := versioning.BuildSchemaArgs{
Specs: versioning.ReadSpecsArgs{
SpecsDir: os.Getenv("CODEGEN_SPECS_DIR"),
SpecsDir: specsDir,
NamespaceFilter: namespaces,
VersionsFilter: apiVersions,
},
Expand Down Expand Up @@ -184,6 +186,20 @@ func main() {
panic(err)
}

case "defaultVersions":
if specsDir == "" {
specsDir = path.Join(wd, "azure-rest-api-specs")
}
versions, err := openapi.ReadReadmes(specsDir)
if err != nil {
panic(err)
}

err = gen.EmitFile(fmt.Sprintf("versions/v%d-default-readme-versions.json", version.Major), versions)
if err != nil {
panic(err)
}

default:
panic(fmt.Sprintf("unknown language %s", languages))
}
Expand Down
91 changes: 91 additions & 0 deletions provider/pkg/openapi/defaultApiVersions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package openapi

import (
"bufio"
"io"
"strings"
)

// # How to determine the default API version of a service?
//
// It's in the `readme.md` file of the service in azure-rest-api-specs, in a yaml code block with a `tag` field.
//
// Example:
//
// ```yaml
// openapi-type: arm
// tag: package-2025-03-01
// ```
//
// There will be other fields as well.
//
// The version can be just a year and month, in which case the API version is the only one released in that month.
//
// The version can also be a composite version like `tag: package-composite-v5`. I don't fully understand those yet.
//
// ## Spec version
// Since we're reading the readme.md files from the azure-rest-api-specs repo, we need to know which commit of the
// file to read. The `main` branch of the spec can be updated with new API versions that SDKs are not using yet. In
// case of stable versions, this is _probably_ ok, in case of preview versions it's more questionable. But we cannot
// simply use the latest stable version since some services barely release stable versions.

// TODO service groups
func ReadDefaultVersionFromReadme(readme io.Reader) (map[Service]ApiVersion, error) {
var version string
var inYamlBlock bool

scanner := bufio.NewScanner(readme)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "```yaml" || line == "``` yaml" {
inYamlBlock = true
}
if inYamlBlock && line == "```" {
inYamlBlock = false
}

if inYamlBlock && strings.HasPrefix(line, "tag: package-") {
version = strings.TrimSpace(strings.TrimPrefix(line, "tag: package-"))
break
}
Comment on lines +47 to +50
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at some more readme files, I think we need to do at least one more step of processing to determine what the version selection is.

Where there's an initial code block such as:

``` yaml
title: ApiManagementClient
description: ApiManagement Client
openapi-type: arm
tag: package-preview-2024-06
```

The tag will correspond with other, conditional code blocks such as:

``` yaml $(tag) == 'package-preview-2024-06'
input-file:
  - Microsoft.ApiManagement/preview/2024-06-01-preview/apigateway.json
  - Microsoft.ApiManagement/preview/2024-06-01-preview/apimallpolicies.json
  - Microsoft.ApiManagement/preview/2024-06-01-preview/apimanagement.json
  - ...
```

The tag can be somewhat arbitrary, but the input-file list is the ultimate output we need.

Overall, I think the parsing approach for the data from a readme file is as follows (though we could possibly short-cut some of this, given we're not needing to support user inputs on the tag to use):

  1. Iterate through each yaml code block.
  2. Check its condition against the current collected state.
  3. Merge the block with the current parsed yaml state.

Worked example

With the two blocks defined above, after parsing the first block would result in a state of:

title: ApiManagementClient
description: ApiManagement Client
openapi-type: arm
tag: package-preview-2024-06

When evaluating the second block, the value of $(tag) would be package-preview-2024-06 and the condition evaluates to true. Therefore the state after parsing the second block is:

title: ApiManagementClient
description: ApiManagement Client
openapi-type: arm
tag: package-preview-2024-06
input-file:
  - Microsoft.ApiManagement/preview/2024-06-01-preview/apigateway.json
  - Microsoft.ApiManagement/preview/2024-06-01-preview/apimallpolicies.json
  - Microsoft.ApiManagement/preview/2024-06-01-preview/apimanagement.json
  - ...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will probably need to turn the list of files back into a list of resources with version instead to produce a lockfile.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

// if version == "" {
// return "", fmt.Errorf("no version found in readme")
// }

// Those occur in sql, security, and synapse
// if strings.HasPrefix(version, "composite-") {
// return "", fmt.Errorf("composite versions are not supported yet")
// }
return map[Service]ApiVersion{Service(""): ApiVersion(version)}, nil
}

// func ReadAllDefaultVersionsFromReadmes(specBaseDir string) (map[string]string, error) {
// versions := make(map[string]string)

// pattern := filepath.Join(specBaseDir, "specification", "*", "resource-manager", "readme.md")
// files, err := filepath.Glob(pattern)
// if err != nil {
// return nil, err
// }

// for _, file := range files {
// f, err := os.Open(file)
// if err != nil {
// return nil, err
// }
// defer f.Close()

// pathParts := strings.Split(file, string(filepath.Separator))
// moduleFolder := pathParts[len(pathParts)-3]

// version, err := ReadDefaultVersionFromReadme(f)
// if err != nil {
// return nil, err
// }
// versions[moduleFolder] = version
// }

// return versions, nil
// }
20 changes: 20 additions & 0 deletions provider/pkg/openapi/defaultApiVersions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package openapi

import (
"os"
"testing"

"github.com/stretchr/testify/require"
)

func TestReadSimpleDefaultVersionFromReadme(t *testing.T) {
f, err := os.Open("testdata/quota-readme.md")
require.NoError(t, err)

expectedVersion := ApiVersion("2025-03-01")
versions, err := ReadDefaultVersionFromReadme(f)
require.NoError(t, err)
require.Len(t, versions, 1)
require.Contains(t, versions, Service(""))
require.Equal(t, expectedVersion, versions[""])
}
70 changes: 69 additions & 1 deletion provider/pkg/openapi/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package openapi

import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
Expand Down Expand Up @@ -80,6 +81,12 @@ type DiscoveryDiagnostics struct {
// module -> resource/type name -> path -> Endpoint
type Endpoints map[ModuleName]map[ResourceName]map[string]*Endpoint

// Services are part of [Service Groups](https://github.com/Azure/azure-rest-api-specs/blob/main/documentation/uniform-versioning.md)
// that some modules use to organize their API. All APIs in a service share the same API version, but it can be different
// across the services of a service group.
type Service string
type ModuleDefaultVersions map[ModuleName]map[Service]ApiVersion

// merge combines e2 into e, which is modified in-place.
func (e Endpoints) merge(e2 Endpoints) {
for moduleName, moduleEndpoints := range e2 {
Expand Down Expand Up @@ -328,6 +335,15 @@ func buildDefaultVersion(versionMap ModuleVersions, defaultResourceVersions map[
}
}

func ReadReadmes(specsDir string) (ModuleDefaultVersions, error) {
readmeLocations, err := readmeLocations(specsDir)
if err != nil {
return nil, err
}

return readDefaultVersionsFromReadmes(readmeLocations, specsDir)
}

// ReadAzureModules finds Azure Open API specs on disk, parses them, and creates in-memory representation of resources,
// collected per Azure Module and API Version - for all API versions.
// Use the namespace "*" to load all available namespaces, or a specific namespace to filter, e.g. "Compute".
Expand Down Expand Up @@ -381,6 +397,42 @@ func ReadAzureModules(specsDir, namespace, apiVersions string) (AzureModules, Di
return modules, diagnostics, nil
}

func readDefaultVersionsFromReadmes(readmePath []string, specsDir string) (ModuleDefaultVersions, error) {
defaultVersionsFromReadmes := make(ModuleDefaultVersions)
for _, readmePath := range readmePath {
moduleNaming, err := resources.GetModuleNameFromReadmePath(version.GetVersion().Major, readmePath)
if err != nil {
return nil, errors.Wrapf(err, "failed to get module name for %q", readmePath)
}

if _, ok := defaultVersionsFromReadmes[moduleNaming.ResolvedName]; !ok {
defaultVersionsFromReadmes[moduleNaming.ResolvedName] = map[Service]ApiVersion{}
}

f, err := os.Open(readmePath)
if err != nil {
fmt.Printf("warning: failed to open readme for %q: %v\n", moduleNaming.ResolvedName, err)
continue
}
defer f.Close()

defaultVersions, err := ReadDefaultVersionFromReadme(f)
if err != nil {
fmt.Printf("warning: failed to get default version for %q: %v\n", moduleNaming.ResolvedName, err)
continue
}

for service, version := range defaultVersions {
if existing, ok := defaultVersionsFromReadmes[moduleNaming.ResolvedName][service]; ok && existing != version {
fmt.Printf("warning: multiple default versions for Service %q in module %q: %s and %s\n", service, moduleNaming.ResolvedName, existing, version)
}
defaultVersionsFromReadmes[moduleNaming.ResolvedName][service] = version
}
}

return defaultVersionsFromReadmes, nil
}

func deprecateAll(resourceSpecs map[string]*ResourceSpec, version ApiVersion) {
for _, resourceSpec := range resourceSpecs {
deprecationMessage := fmt.Sprintf(
Expand Down Expand Up @@ -465,6 +517,22 @@ func SortApiVersions(versions []ApiVersion) {
})
}

func readmeLocations(specsDir string) ([]string, error) {
pattern := filepath.Join(specsDir, "specification", "*", "resource-manager", "readme.md")
files, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}

servicePattern := filepath.Join(specsDir, "specification", "*", "resource-manager", "*", "*", "readme.md")
files2, err := filepath.Glob(servicePattern)
if err != nil {
return nil, err
}

return append(files, files2...), nil
}

// swaggerLocations returns a slice of URLs of all known Azure Resource Manager swagger files.
// namespace and apiVersion can be blank to return all files, or can be used to filter the results.
func swaggerLocations(specsDir, namespace, apiVersions string) ([]string, error) {
Expand Down Expand Up @@ -531,7 +599,7 @@ func exclude(filePath string) bool {
// addAPIPath considers whether an API path contains resources and/or invokes and adds corresponding entries to the
// module map. Modules are mutated in-place.
func (modules AzureModules) addAPIPath(specsDir, fileLocation, path string, swagger *Spec) DiscoveryDiagnostics {
moduleNaming, err := resources.GetModuleName(version.GetVersion().Major, filepath.Join(specsDir, fileLocation), path)
moduleNaming, err := resources.GetModuleNameFromSwaggerPath(version.GetVersion().Major, filepath.Join(specsDir, fileLocation), path)
if err != nil {
return DiscoveryDiagnostics{
ModuleNameErrors: []ModuleNameError{
Expand Down
Loading
Loading