Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

compose: Add azd add support for storage accounts (blob service) #4765

Merged
merged 6 commits into from
Feb 11, 2025
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
2 changes: 2 additions & 0 deletions cli/azd/internal/cmd/add/add_configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ func Configure(

r.Name = "redis"
return r, nil
case project.ResourceTypeStorage:
return fillStorageDetails(ctx, r, console, p)
default:
return r, nil
}
Expand Down
129 changes: 129 additions & 0 deletions cli/azd/internal/cmd/add/add_configure_storage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package add

import (
"context"
"errors"
"fmt"
"strings"

"github.com/azure/azure-dev/cli/azd/internal/names"
"github.com/azure/azure-dev/cli/azd/pkg/input"
"github.com/azure/azure-dev/cli/azd/pkg/output"
"github.com/azure/azure-dev/cli/azd/pkg/project"
)

const (
StorageDataTypeBlob = "Blobs"
)

func allStorageDataTypes() []string {
return []string{StorageDataTypeBlob}
}

func fillStorageDetails(
ctx context.Context,
r *project.ResourceConfig,
console input.Console,
p PromptOptions) (*project.ResourceConfig, error) {
if _, exists := p.PrjConfig.Resources["storage"]; exists {
return nil, fmt.Errorf("only one Storage resource is allowed at this time")
}

if r.Name == "" {
r.Name = "storage"
}

modelProps, ok := r.Props.(project.StorageProps)
if !ok {
return nil, fmt.Errorf("invalid resource properties")
}

selectedDataTypes, err := selectStorageDataTypes(ctx, console)
if err != nil {
return nil, err
}

for _, option := range selectedDataTypes {
switch option {
case StorageDataTypeBlob:
if err := fillBlobDetails(ctx, console, &modelProps); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported data type: %s", option)
}
}

r.Props = modelProps
return r, nil
}

func selectStorageDataTypes(ctx context.Context, console input.Console) ([]string, error) {
var selectedDataOptions []string
for {
var err error
selectedDataOptions, err = console.MultiSelect(ctx, input.ConsoleOptions{
Message: "What type of data do you want to store?",
Options: allStorageDataTypes(),
DefaultValue: []string{StorageDataTypeBlob},
})
if err != nil {
return nil, err
}

if len(selectedDataOptions) == 0 {
console.Message(ctx, output.WithErrorFormat("At least one data type must be selected"))
continue
}
break
}
return selectedDataOptions, nil
}

func fillBlobDetails(ctx context.Context, console input.Console, modelProps *project.StorageProps) error {
for {
containerName, err := console.Prompt(ctx, input.ConsoleOptions{
Message: "Input a blob container name to be created:",
Help: "Blob container name\n\n" +
"A blob container organizes a set of blobs, similar to a directory in a file system.",
})
if err != nil {
return err
}

if err := validateContainerName(containerName); err != nil {
console.Message(ctx, err.Error())
continue
}
modelProps.Containers = append(modelProps.Containers, containerName)
break
}
return nil
}

// validateContainerName validates storage account container names.
// Reference:
// https://learn.microsoft.com/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata
func validateContainerName(name string) error {
if len(name) < 3 {
return errors.New("name must be 3 characters or more")
}

if strings.Contains(name, "--") {
return errors.New("name cannot contain consecutive hyphens")
}

if strings.ToLower(name) != name {
return errors.New("name must be all lower case")
}

err := names.ValidateLabelName(name)
JeffreyCA marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return err
}

return nil
}
6 changes: 6 additions & 0 deletions cli/azd/internal/cmd/add/add_preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ func Metadata(r *project.ResourceConfig) resourceMeta {
res.UseEnvVars = []string{
"AZURE_OPENAI_ENDPOINT",
}
case project.ResourceTypeStorage:
res.AzureResourceType = "Microsoft.Storage/storageAccounts"
res.UseEnvVars = []string{
"AZURE_STORAGE_ACCOUNT_NAME",
"AZURE_STORAGE_BLOB_ENDPOINT",
}
}
return res
}
Expand Down
11 changes: 11 additions & 0 deletions cli/azd/internal/cmd/add/add_select.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func (a *AddAction) selectMenu() []Menu {
{Namespace: "db", Label: "Database", SelectResource: selectDatabase},
{Namespace: "host", Label: "Host service"},
{Namespace: "ai.openai", Label: "Azure OpenAI", SelectResource: a.selectOpenAi},
{Namespace: "storage", Label: "Storage account", SelectResource: selectStorage},
}
}

Expand Down Expand Up @@ -59,3 +60,13 @@ func selectDatabase(
r.Type = resourceTypesDisplayMap[resourceTypesDisplay[dbOption]]
return r, nil
}

func selectStorage(
console input.Console,
ctx context.Context,
p PromptOptions) (*project.ResourceConfig, error) {
r := &project.ResourceConfig{}
r.Type = project.ResourceTypeStorage
r.Props = project.StorageProps{}
return r, nil
}
2 changes: 1 addition & 1 deletion cli/azd/internal/names/label.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"strings"
)

var rfc1123LabelRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$`)
var rfc1123LabelRegex = regexp.MustCompile(`^(?:[A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]{0,61}[A-Za-z0-9])$`)
weikanglim marked this conversation as resolved.
Show resolved Hide resolved

// ValidateLabelName checks if the given name is a valid RFC 1123 Label name.
func ValidateLabelName(name string) error {
Expand Down
35 changes: 35 additions & 0 deletions cli/azd/internal/names/label_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package names

import (
"strings"
"testing"
)

Expand Down Expand Up @@ -68,4 +69,38 @@ func TestLabelNameEdgeCases(t *testing.T) {
}
}

func TestValidateLabelName(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{"SingleLowercase", "a", false},
{"MaxLength", strings.Repeat("a", 63), false},
{"WithHyphen", "a-b-c", false},
{"EmptyString", "", true},
{"SingleUppercase", "Z", true},
{"TooLong", strings.Repeat("a", 64), true},
{"StartWithUppercase", "Abcdef", true},
{"EndsWithUppercase", "abcdefG", true},
{"InvalidSingleHyphen", "-", true},
{"InvalidSingleSymbol", "!", true},
{"LabelStartingWithHyphen", "-abc", true},
{"LabelEndingWithHyphen", "abc-", true},
{"LabelWithInvalidCharacters", "ab#cd", true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateLabelName(tt.input)
if tt.wantErr && err == nil {
t.Errorf("expected error for input %q, got none", tt.input)
}
if !tt.wantErr && err != nil {
t.Errorf("expected no error for input %q, got %q", tt.input, err)
}
})
}
}

//cspell:enable
6 changes: 3 additions & 3 deletions cli/azd/internal/repository/app_init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,13 @@ func TestInitializer_prjConfigFromDetect(t *testing.T) {
interactions: []string{
// prompt for db -- hit multiple validation cases
"my app db",
"n",
"N",
"my$special$db",
"n",
"N",
"mongodb", // fill in db name
// prompt for db -- hit multiple validation cases
"my$special$db",
"n",
"N",
"postgres", // fill in db name
},
want: project.ProjectConfig{
Expand Down
4 changes: 3 additions & 1 deletion cli/azd/internal/scaffold/scaffold_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ func TestExecInfra(t *testing.T) {
DbCosmosMongo: &DatabaseCosmosMongo{
DatabaseName: "appdb",
},
DbRedis: &DatabaseRedis{},
DbRedis: &DatabaseRedis{},
StorageAccount: &StorageAccount{},
Services: []ServiceSpec{
{
Name: "api",
Expand All @@ -110,6 +111,7 @@ func TestExecInfra(t *testing.T) {
DbPostgres: &DatabaseReference{
DatabaseName: "appdb",
},
StorageAccount: &StorageReference{},
},
{
Name: "web",
Expand Down
11 changes: 11 additions & 0 deletions cli/azd/internal/scaffold/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type InfraSpec struct {
DbCosmosMongo *DatabaseCosmosMongo
DbRedis *DatabaseRedis

StorageAccount *StorageAccount

// ai models
AIModels []AIModel
}
Expand Down Expand Up @@ -54,6 +56,10 @@ type AIModelModel struct {
Version string
}

type StorageAccount struct {
Containers []string
}

type ServiceSpec struct {
Name string
Port int
Expand All @@ -71,6 +77,8 @@ type ServiceSpec struct {
DbCosmosMongo *DatabaseReference
DbRedis *DatabaseReference

StorageAccount *StorageReference
JeffreyCA marked this conversation as resolved.
Show resolved Hide resolved

// AI model connections
AIModels []AIModelReference
}
Expand All @@ -95,6 +103,9 @@ type AIModelReference struct {
Name string
}

type StorageReference struct {
}

func containerAppExistsParameter(serviceName string) Parameter {
return Parameter{
Name: BicepName(serviceName) + "Exists",
Expand Down
19 changes: 19 additions & 0 deletions cli/azd/pkg/project/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func AllResourceTypes() []ResourceType {
ResourceTypeDbMongo,
ResourceTypeHostContainerApp,
ResourceTypeOpenAiModel,
ResourceTypeStorage,
}
}

Expand All @@ -27,6 +28,7 @@ const (
ResourceTypeDbMongo ResourceType = "db.mongo"
ResourceTypeHostContainerApp ResourceType = "host.containerapp"
ResourceTypeOpenAiModel ResourceType = "ai.openai.model"
ResourceTypeStorage ResourceType = "storage"
)

func (r ResourceType) String() string {
Expand All @@ -41,6 +43,8 @@ func (r ResourceType) String() string {
return "Container App"
case ResourceTypeOpenAiModel:
return "Open AI Model"
case ResourceTypeStorage:
return "Storage Account"
}

return ""
Expand Down Expand Up @@ -89,6 +93,11 @@ func (r *ResourceConfig) MarshalYAML() (interface{}, error) {
if err != nil {
return nil, err
}
case ResourceTypeStorage:
err := marshalRawProps(raw.Props.(StorageProps))
if err != nil {
return nil, err
}
}

return raw, nil
Expand Down Expand Up @@ -128,6 +137,12 @@ func (r *ResourceConfig) UnmarshalYAML(value *yaml.Node) error {
return err
}
raw.Props = cap
case ResourceTypeStorage:
sp := StorageProps{}
if err := unmarshalProps(&sp); err != nil {
return err
}
raw.Props = sp
}

*r = ResourceConfig(raw)
Expand Down Expand Up @@ -155,3 +170,7 @@ type AIModelPropsModel struct {
Name string `yaml:"name,omitempty"`
Version string `yaml:"version,omitempty"`
}

type StorageProps struct {
Containers []string `yaml:"containers,omitempty"`
}
10 changes: 10 additions & 0 deletions cli/azd/pkg/project/scaffold_gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,14 @@ func infraSpec(projectConfig *ProjectConfig) (*scaffold.InfraSpec, error) {
Version: props.Model.Version,
},
})
case ResourceTypeStorage:
if infraSpec.StorageAccount != nil {
return nil, fmt.Errorf("only one storage account resource is currently allowed")
}
props := res.Props.(StorageProps)
infraSpec.StorageAccount = &scaffold.StorageAccount{
Containers: props.Containers,
}
}
}

Expand Down Expand Up @@ -273,6 +281,8 @@ func mapHostUses(
backendMapping[use] = res.Name // record the backend -> frontend mapping
case ResourceTypeOpenAiModel:
svcSpec.AIModels = append(svcSpec.AIModels, scaffold.AIModelReference{Name: use})
case ResourceTypeStorage:
svcSpec.StorageAccount = &scaffold.StorageReference{}
}
}

Expand Down
3 changes: 3 additions & 0 deletions cli/azd/resources/scaffold/templates/main.bicept
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,7 @@ output AZURE_RESOURCE_REDIS_ID string = resources.outputs.AZURE_RESOURCE_REDIS_I
{{- if .DbPostgres}}
output AZURE_RESOURCE_{{alphaSnakeUpper .DbPostgres.DatabaseName}}_ID string = resources.outputs.AZURE_RESOURCE_{{alphaSnakeUpper .DbPostgres.DatabaseName}}_ID
{{- end}}
{{- if .StorageAccount }}
output AZURE_RESOURCE_STORAGE_ID string = resources.outputs.AZURE_RESOURCE_STORAGE_ID
{{- end}}
{{ end}}
Loading
Loading