Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b218fbe33 | ||
|
|
f32159b3af | ||
|
|
a44894d877 | ||
|
|
8f552ecacc | ||
|
|
a48b658d78 |
@@ -68,6 +68,8 @@ HAR/*.har
|
||||
*.token
|
||||
secrets/private_key.asc
|
||||
secrets/.s3cfg_registry
|
||||
secrets/.s3cfg_provider
|
||||
secrets/.s3cfg*
|
||||
secrets/pearlharbor_registry.txt
|
||||
secrets/id_ed25519.txt
|
||||
|
||||
|
||||
@@ -53,16 +53,16 @@ cd /home/naeel/TF/tf_provider
|
||||
|
||||
| Стенд | Namespace | Бинарники в S3 |
|
||||
|---|---|---|
|
||||
| dev | `nubes-dev` | `nubes-terraform-registry/tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes/0.0.1/` |
|
||||
| test | `nubes-test` | `.../nubes-test/nubes/0.0.1/` |
|
||||
| prod | `nubes` | `.../nubes/nubes/0.0.1/` |
|
||||
| dev | `nubes-dev` | `terraform-registry/tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes/0.0.1/` |
|
||||
| test | `nubes-test` | `terraform-registry/tf-registry.containerk8s.services.ngcloud.ru/nubes-test/nubes/0.0.1/` |
|
||||
| prod | `nubes` | `terraform-registry/tf-registry.containerk8s.services.ngcloud.ru/nubes/nubes/0.0.1/` |
|
||||
|
||||
## Предусловия — ПРОВЕРЕНО, всё готово
|
||||
- Go 1.23.1, docker 29.1.3, `mc`, GPG (`secrets/private_key.asc`, `public_key.asc`).
|
||||
- Токены API: `secrets/{dev,test,prod}.token` на месте.
|
||||
- API-эндпоинты доступны (HTTP 403 без токена — ожидаемо, токен передаёт 01).
|
||||
- `TOOLS/config/<стенд>/operation_timeouts.json` на месте.
|
||||
- `registry.env`: `REGISTRY_HOSTNAME=tf-registry.containerk8s.services.ngcloud.ru`, `S3_BUCKET=nubes-terraform-registry`.
|
||||
- `registry.env`: `REGISTRY_HOSTNAME=tf-registry.containerk8s.services.ngcloud.ru`, `S3_BUCKET=terraform-registry`.
|
||||
|
||||
## Чего НЕ делать
|
||||
- НЕ запускать `04_build_and_publish_docs.sh` (документация не нужна сейчас).
|
||||
|
||||
@@ -41,6 +41,7 @@ type OperationSpec struct {
|
||||
ID int `yaml:"id"`
|
||||
Kind string `yaml:"kind"`
|
||||
Action string `yaml:"action"`
|
||||
Modifier string `yaml:"modifier,omitempty"`
|
||||
Subresource string `yaml:"subresource,omitempty"`
|
||||
Man string `yaml:"man,omitempty"`
|
||||
Params []ParamSpec `yaml:"params"`
|
||||
|
||||
@@ -24,11 +24,12 @@ import (
|
||||
"resource-generator/internal/types"
|
||||
)
|
||||
|
||||
// LoadSpecs загружает все YAML-спеки из директории и строит GenResource/GenSubresource/GenAction.
|
||||
func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types.GenAction, error) {
|
||||
// LoadSpecs загружает все YAML-спеки из директории и строит модели всех ресурсов.
|
||||
func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types.GenAction, []types.GenModifier, error) {
|
||||
var services []types.GenResource
|
||||
var subs []types.GenSubresource
|
||||
var actions []types.GenAction
|
||||
var modifiers []types.GenModifier
|
||||
domainServiceIDsSet := map[int]struct{}{}
|
||||
walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
@@ -56,6 +57,29 @@ func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types
|
||||
modifyParams := []types.Param{}
|
||||
supportsSuspendDestroy := false
|
||||
for _, op := range spec.Operations {
|
||||
if op.Kind == "modifier" {
|
||||
name := strings.TrimSpace(op.Modifier)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(op.Action)
|
||||
}
|
||||
modifier := types.GenModifier{
|
||||
ServiceName: spec.Name,
|
||||
ServiceID: spec.ServiceID,
|
||||
ModifierName: name,
|
||||
OperationName: op.Action,
|
||||
Params: ConvertParams(op.Params),
|
||||
}
|
||||
modifier.SchemaParams = modifier.Params
|
||||
for idx := range modifier.SchemaParams {
|
||||
if modifier.SchemaParams[idx].HasSubParams {
|
||||
modifier.SchemaParams[idx].IsJson = true
|
||||
}
|
||||
}
|
||||
params.Analyze(&modifier.UsesBool, &modifier.UsesInt64, &modifier.UsesString, &modifier.HasDefaults, &modifier.NeedsBoolDefault, &modifier.NeedsInt64Default, &modifier.NeedsStringDefault, modifier.SchemaParams)
|
||||
modifier.NeedsJsonPlanMod = params.AnalyzeJsonPlanMod(modifier.SchemaParams)
|
||||
modifiers = append(modifiers, modifier)
|
||||
continue
|
||||
}
|
||||
if op.Kind != "instance" {
|
||||
continue
|
||||
}
|
||||
@@ -192,7 +216,7 @@ func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types
|
||||
})
|
||||
|
||||
if walkErr != nil {
|
||||
return nil, nil, nil, walkErr
|
||||
return nil, nil, nil, nil, walkErr
|
||||
}
|
||||
|
||||
domainServiceIDs := make([]int, 0, len(domainServiceIDsSet))
|
||||
@@ -221,8 +245,14 @@ func LoadSpecs(dir string) ([]types.GenResource, []types.GenSubresource, []types
|
||||
}
|
||||
return actions[i].ServiceName < actions[j].ServiceName
|
||||
})
|
||||
sort.Slice(modifiers, func(i, j int) bool {
|
||||
if modifiers[i].ServiceName == modifiers[j].ServiceName {
|
||||
return modifiers[i].ModifierName < modifiers[j].ModifierName
|
||||
}
|
||||
return modifiers[i].ServiceName < modifiers[j].ServiceName
|
||||
})
|
||||
|
||||
return services, subs, actions, nil
|
||||
return services, subs, actions, modifiers, nil
|
||||
}
|
||||
|
||||
// ConvertParams конвертирует []ParamSpec → []Param с нормализацией типов.
|
||||
@@ -313,6 +343,7 @@ var KnownKinds = map[string]bool{
|
||||
"instance": true,
|
||||
"subresource": true,
|
||||
"action": true,
|
||||
"modifier": true,
|
||||
}
|
||||
|
||||
// ValidateSpec проверяет YAML-спек на обязательные поля и неизвестные kinds.
|
||||
@@ -329,7 +360,7 @@ func ValidateSpec(path string, spec *types.ServiceSpec) error {
|
||||
return fmt.Errorf("operation[%d] %q: missing required field: kind", i, op.Name)
|
||||
}
|
||||
if !KnownKinds[op.Kind] {
|
||||
return fmt.Errorf("operation[%d] %q: unknown kind %q (valid: instance, subresource, action)", i, op.Name, op.Kind)
|
||||
return fmt.Errorf("operation[%d] %q: unknown kind %q (valid: instance, subresource, action, modifier)", i, op.Name, op.Kind)
|
||||
}
|
||||
if op.Action == "" {
|
||||
return fmt.Errorf("operation[%d] %q (kind=%s): missing required field: action", i, op.Name, op.Kind)
|
||||
@@ -337,6 +368,9 @@ func ValidateSpec(path string, spec *types.ServiceSpec) error {
|
||||
if op.Kind == "subresource" && op.Subresource == "" {
|
||||
return fmt.Errorf("operation[%d] %q (kind=subresource): missing required field: subresource", i, op.Name)
|
||||
}
|
||||
if op.Kind == "modifier" && strings.TrimSpace(op.Action) == "" {
|
||||
return fmt.Errorf("operation[%d] %q (kind=modifier): missing required field: action", i, op.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package templates
|
||||
|
||||
const Modifier = `package resources_gen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"terraform-provider-nubes/internal/core"
|
||||
"terraform-provider-nubes/internal/resources_core"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||
{{- if .NeedsBoolDefault }}
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
|
||||
{{- end }}
|
||||
{{- if .NeedsInt64Default }}
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
|
||||
{{- end }}
|
||||
{{- if .NeedsStringDefault }}
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
|
||||
{{- end }}
|
||||
{{- if .NeedsJsonPlanMod }}
|
||||
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||
{{- end }}
|
||||
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||
)
|
||||
|
||||
// Code generated by TOOLS/resource-generator. DO NOT EDIT.
|
||||
// Modifier: {{.ServiceName}}.{{.ModifierName}}
|
||||
|
||||
var _ resource.Resource = &{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource{}
|
||||
|
||||
type {{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource struct {
|
||||
client *core.UniversalClient
|
||||
}
|
||||
|
||||
type {{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Model struct {
|
||||
ID types.String ` + "`" + `tfsdk:"id"` + "`" + `
|
||||
{{ToCamel .ServiceName}}ID types.String ` + "`" + `tfsdk:"{{ToSnake .ServiceName}}_id"` + "`" + `
|
||||
OperationTimeout types.String ` + "`" + `tfsdk:"operation_timeout"` + "`" + `
|
||||
LogLevel types.String ` + "`" + `tfsdk:"log_level"` + "`" + `
|
||||
{{- range .SchemaParams }}
|
||||
{{ToCamel .Code}} {{ParamType .}} ` + "`" + `tfsdk:"{{ToSnake .Code}}"` + "`" + `
|
||||
{{- end }}
|
||||
}
|
||||
|
||||
func New{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource() resource.Resource {
|
||||
return &{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource{}
|
||||
}
|
||||
|
||||
func (r *{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||
resp.TypeName = req.ProviderTypeName + "_{{.ServiceName}}_{{.ModifierName}}"
|
||||
}
|
||||
|
||||
func (r *{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||
attrs := map[string]schema.Attribute{
|
||||
"id": schema.StringAttribute{Computed: true},
|
||||
"{{ToSnake .ServiceName}}_id": schema.StringAttribute{Required: true},
|
||||
"operation_timeout": schema.StringAttribute{Optional: true},
|
||||
"log_level": schema.StringAttribute{Optional: true},
|
||||
{{- range .SchemaParams }}
|
||||
"{{ToSnake .Code}}": schema.{{if eq (ParamType .) "types.Bool"}}Bool{{else if eq (ParamType .) "types.Int64"}}Int64{{else}}String{{end}}Attribute{
|
||||
{{- if and .Required (eq (ParamDefaultExpr .) "") }}Required: true,{{else}}Optional: true,{{end}}
|
||||
{{- if ne (ParamDefaultExpr .) "" }}Computed: true, Default: {{ParamDefaultExpr .}},{{- end }}
|
||||
{{- if ne (ParamDescription .) "" }}MarkdownDescription: {{ParamDescription .}},{{end}}
|
||||
{{- if .Sensitive }}Sensitive: true,{{end}}
|
||||
{{- if .IsJson }}PlanModifiers: []planmodifier.String{resources_core.JsonNormalize()},{{end}}
|
||||
},
|
||||
{{- end }}
|
||||
}
|
||||
resp.Schema = schema.Schema{Attributes: attrs}
|
||||
}
|
||||
|
||||
func (r *{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||
var plan {{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Model
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() { return }
|
||||
{{- range .SchemaParams }}
|
||||
{{- if and .Required (eq (ParamDefaultExpr .) "") }}
|
||||
if plan.{{ToCamel .Code}}.IsNull() || plan.{{ToCamel .Code}}.IsUnknown() {
|
||||
resp.Diagnostics.AddError("Missing required attribute", "{{ToSnake .Code}} is required.")
|
||||
return
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
instanceUID := strings.TrimSpace(plan.{{ToCamel .ServiceName}}ID.ValueString())
|
||||
if instanceUID == "" { resp.Diagnostics.AddError("Ошибка клиента", "отсутствует идентификатор экземпляра"); return }
|
||||
params := resources_core.CompactParams(map[string]string{
|
||||
{{- range .SchemaParams }}
|
||||
"{{.Code}}": {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}},
|
||||
{{- end }}
|
||||
})
|
||||
operationTimeout := ""
|
||||
if !plan.OperationTimeout.IsNull() && !plan.OperationTimeout.IsUnknown() { operationTimeout = plan.OperationTimeout.ValueString() }
|
||||
if !plan.LogLevel.IsNull() && !plan.LogLevel.IsUnknown() { ctx = core.CtxWithLogLevel(ctx, plan.LogLevel.ValueString()) }
|
||||
if err := resources_core.RunOperationByCodeWithTimeout(ctx, r.client, instanceUID, "{{.OperationName}}", params, operationTimeout); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error()); return
|
||||
}
|
||||
plan.ID = types.StringValue(resources_core.BuildActionID(instanceUID, "{{.OperationName}}", "{{.ModifierName}}"))
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||
}
|
||||
|
||||
func (r *{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||
var state {{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Model
|
||||
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||
if resp.Diagnostics.HasError() { return }
|
||||
if state.ID.IsNull() || state.ID.IsUnknown() { return }
|
||||
if r.client != nil {
|
||||
remove, err := resources_core.ShouldRemoveFromState(ctx, r.client, state.{{ToCamel .ServiceName}}ID.ValueString())
|
||||
if err != nil { resp.Diagnostics.AddError("Ошибка клиента", err.Error()); return }
|
||||
if remove { resp.State.RemoveResource(ctx); return }
|
||||
}
|
||||
newState, diags := resources_core.RefreshResourceState(ctx, r.client, state.{{ToCamel .ServiceName}}ID.ValueString(), {{.ServiceID}}, state, nil, []resources_core.InputField{
|
||||
{{- range .SchemaParams }}
|
||||
{Code: "{{.Code}}", Field: "{{ToCamel .Code}}", Type: "{{.Type}}"},
|
||||
{{- end }}
|
||||
})
|
||||
resp.Diagnostics.Append(diags...)
|
||||
if resp.Diagnostics.HasError() { return }
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...)
|
||||
}
|
||||
|
||||
func (r *{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||
var plan {{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Model
|
||||
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||
if resp.Diagnostics.HasError() { return }
|
||||
{{- range .SchemaParams }}
|
||||
{{- if and .Required (eq (ParamDefaultExpr .) "") }}
|
||||
if plan.{{ToCamel .Code}}.IsNull() || plan.{{ToCamel .Code}}.IsUnknown() {
|
||||
resp.Diagnostics.AddError("Missing required attribute", "{{ToSnake .Code}} is required.")
|
||||
return
|
||||
}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
instanceUID := strings.TrimSpace(plan.{{ToCamel .ServiceName}}ID.ValueString())
|
||||
params := resources_core.CompactParams(map[string]string{
|
||||
{{- range .SchemaParams }}
|
||||
"{{.Code}}": {{ParamFormat . (printf "plan.%s" (ToCamel .Code))}},
|
||||
{{- end }}
|
||||
})
|
||||
operationTimeout := ""
|
||||
if !plan.OperationTimeout.IsNull() && !plan.OperationTimeout.IsUnknown() { operationTimeout = plan.OperationTimeout.ValueString() }
|
||||
if !plan.LogLevel.IsNull() && !plan.LogLevel.IsUnknown() { ctx = core.CtxWithLogLevel(ctx, plan.LogLevel.ValueString()) }
|
||||
if err := resources_core.RunOperationByCodeWithTimeout(ctx, r.client, instanceUID, "{{.OperationName}}", params, operationTimeout); err != nil {
|
||||
resp.Diagnostics.AddError("Ошибка клиента", err.Error()); return
|
||||
}
|
||||
plan.ID = types.StringValue(resources_core.BuildActionID(instanceUID, "{{.OperationName}}", "{{.ModifierName}}"))
|
||||
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||
}
|
||||
|
||||
func (r *{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||
// No-op: no confirmed inverse payload exists for this modifier.
|
||||
}
|
||||
|
||||
func (r *{{ToCamel (printf "%s_%s" .ServiceName .ModifierName)}}Resource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||
if req.ProviderData == nil { return }
|
||||
client, ok := req.ProviderData.(*core.UniversalClient)
|
||||
if !ok { resp.Diagnostics.AddError("Error", "Invalid client type"); return }
|
||||
r.client = client
|
||||
}
|
||||
`
|
||||
@@ -145,3 +145,22 @@ type GenAction struct {
|
||||
NeedsJsonPlanMod bool
|
||||
NeedsStringsImport bool
|
||||
}
|
||||
|
||||
// GenModifier — отдельный ресурс для отложенной parent-level modify операции.
|
||||
// Delete намеренно не содержит rollback: API-контракт обратного payload не подтверждён.
|
||||
type GenModifier struct {
|
||||
ServiceName string
|
||||
ServiceID int
|
||||
ModifierName string
|
||||
OperationName string
|
||||
Params []Param
|
||||
SchemaParams []Param
|
||||
UsesBool bool
|
||||
UsesInt64 bool
|
||||
UsesString bool
|
||||
HasDefaults bool
|
||||
NeedsBoolDefault bool
|
||||
NeedsInt64Default bool
|
||||
NeedsStringDefault bool
|
||||
NeedsJsonPlanMod bool
|
||||
}
|
||||
|
||||
@@ -135,8 +135,38 @@ func WriteActionResource(outDir string, act types.GenAction) error {
|
||||
return os.WriteFile(filePath, formatted, 0644)
|
||||
}
|
||||
|
||||
// WriteModifierResource генерирует отдельный ресурс для parent-level modify.
|
||||
func WriteModifierResource(outDir string, modifier types.GenModifier) error {
|
||||
name := fmt.Sprintf("%s_%s", modifier.ServiceName, modifier.ModifierName)
|
||||
fileName := fmt.Sprintf("%d_%s_modifier.go", modifier.ServiceID, name)
|
||||
filePath := filepath.Join(outDir, fileName)
|
||||
|
||||
tpl, err := template.New("modifier").Funcs(template.FuncMap{
|
||||
"ToCamel": helpers.ToCamel,
|
||||
"ToSnake": helpers.ToSnake,
|
||||
"ParamType": helpers.ParamType,
|
||||
"ParamDefaultExpr": helpers.ParamDefaultExpr,
|
||||
"ParamFormat": helpers.ParamFormat,
|
||||
"ParamDescription": helpers.ParamDescription,
|
||||
"bt": func() string { return "`" },
|
||||
}).Parse(templates.Modifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, modifier); err != nil {
|
||||
return err
|
||||
}
|
||||
formatted, err := helpers.FormatSourceOrWarn(filePath, buf.Bytes())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filePath, formatted, 0644)
|
||||
}
|
||||
|
||||
// WriteRegistry генерирует registry.go со списком всех ресурсов.
|
||||
func WriteRegistry(outDir string, services []types.GenResource, subs []types.GenSubresource, actions []types.GenAction) error {
|
||||
func WriteRegistry(outDir string, services []types.GenResource, subs []types.GenSubresource, actions []types.GenAction, modifiers []types.GenModifier) error {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("package resources_gen\n\n")
|
||||
buf.WriteString("import \"github.com/hashicorp/terraform-plugin-framework/resource\"\n\n")
|
||||
@@ -152,6 +182,9 @@ func WriteRegistry(outDir string, services []types.GenResource, subs []types.Gen
|
||||
for _, act := range actions {
|
||||
buf.WriteString(fmt.Sprintf("\t\tNew%[1]sResource,\n", helpers.ToCamel(act.ServiceName+"_"+act.ActionName)))
|
||||
}
|
||||
for _, modifier := range modifiers {
|
||||
buf.WriteString(fmt.Sprintf("\t\tNew%[1]sResource,\n", helpers.ToCamel(modifier.ServiceName+"_"+modifier.ModifierName)))
|
||||
}
|
||||
buf.WriteString("\t}\n")
|
||||
buf.WriteString("}\n")
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ func run() error {
|
||||
return fmt.Errorf("NUBES_RESOURCES_GEN_DIR is required — must point to generated/{stand}/go/")
|
||||
}
|
||||
|
||||
instanceResources, subresources, actions, err := loader.LoadSpecs(resourcesDir)
|
||||
instanceResources, subresources, actions, modifiers, err := loader.LoadSpecs(resourcesDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -72,6 +72,12 @@ func run() error {
|
||||
writeErrs = append(writeErrs, serviceWriteErr{kind: "action", name: name, err: err})
|
||||
}
|
||||
}
|
||||
for _, modifier := range modifiers {
|
||||
if err := writers.WriteModifierResource(outDir, modifier); err != nil {
|
||||
name := fmt.Sprintf("%s_%s", modifier.ServiceName, modifier.ModifierName)
|
||||
writeErrs = append(writeErrs, serviceWriteErr{kind: "modifier", name: name, err: err})
|
||||
}
|
||||
}
|
||||
|
||||
if len(writeErrs) > 0 {
|
||||
var b strings.Builder
|
||||
@@ -82,7 +88,7 @@ func run() error {
|
||||
return errors.New(strings.TrimSpace(b.String()))
|
||||
}
|
||||
|
||||
if err := writers.WriteRegistry(outDir, instanceResources, subresources, actions); err != nil {
|
||||
if err := writers.WriteRegistry(outDir, instanceResources, subresources, actions, modifiers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ rm -f "$FAILURES_FILE"
|
||||
# Auto-rebuild yaml-generator if sources are newer than binary
|
||||
BIN="${ROOT_DIR}/TOOLS/bin/yaml-generator"
|
||||
SRC="${ROOT_DIR}/TOOLS/yaml-generator/"
|
||||
if [[ ! -x "$BIN" ]] || [[ "$SRC" -nt "$BIN" ]]; then
|
||||
if [[ ! -x "$BIN" ]] || find "$SRC" -type f -newer "$BIN" -print -quit | grep -q .; then
|
||||
echo "Building yaml-generator..."
|
||||
(cd "${ROOT_DIR}/TOOLS/yaml-generator" && go build -o "$BIN" .)
|
||||
fi
|
||||
|
||||
@@ -93,9 +93,20 @@ echo "Generating resources from unified YAML specs..."
|
||||
TMP_GEN_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_GEN_DIR"' EXIT
|
||||
|
||||
RESOURCE_GENERATOR_BIN="${ROOT_DIR}/TOOLS/bin/resource-generator"
|
||||
RESOURCE_GENERATOR_SRC="${ROOT_DIR}/TOOLS/resource-generator"
|
||||
DOCS_GENERATOR_BIN="${ROOT_DIR}/TOOLS/bin/docs-generator"
|
||||
DOCS_GENERATOR_SRC="${ROOT_DIR}/TOOLS/docs-generator"
|
||||
if [[ ! -x "$RESOURCE_GENERATOR_BIN" ]] || find "$RESOURCE_GENERATOR_SRC" -type f -newer "$RESOURCE_GENERATOR_BIN" -print -quit | grep -q .; then
|
||||
(cd "$RESOURCE_GENERATOR_SRC" && go build -o "$RESOURCE_GENERATOR_BIN" .)
|
||||
fi
|
||||
if [[ ! -x "$DOCS_GENERATOR_BIN" ]] || find "$DOCS_GENERATOR_SRC" -type f -newer "$DOCS_GENERATOR_BIN" -print -quit | grep -q .; then
|
||||
(cd "$DOCS_GENERATOR_SRC" && go build -o "$DOCS_GENERATOR_BIN" .)
|
||||
fi
|
||||
|
||||
NUBES_RESOURCES_DIR="$RESOURCES_YAML_DIR" \
|
||||
NUBES_RESOURCES_GEN_DIR="$TMP_GEN_DIR" \
|
||||
${ROOT_DIR}/TOOLS/bin/resource-generator
|
||||
"$RESOURCE_GENERATOR_BIN"
|
||||
|
||||
mkdir -p "$GO_OUTPUT_DIR"
|
||||
rm -rf "${GO_OUTPUT_DIR}"/*
|
||||
@@ -113,7 +124,7 @@ if [[ "$DOCS_API_ENDPOINT" != *"/index.cfm"* ]] && [[ "$DOCS_API_ENDPOINT" != *"
|
||||
DOCS_API_ENDPOINT="${DOCS_API_ENDPOINT%/}/index.cfm"
|
||||
fi
|
||||
|
||||
${ROOT_DIR}/TOOLS/bin/docs-generator \
|
||||
"$DOCS_GENERATOR_BIN" \
|
||||
-resources "$RESOURCES_YAML_DIR" \
|
||||
-docs "$DOCS_DIR" \
|
||||
-services "$SERVICES_LIST_PATH" \
|
||||
|
||||
@@ -38,6 +38,9 @@ if [[ -z "$PROFILE_DIR" ]]; then
|
||||
exit 2
|
||||
fi
|
||||
|
||||
"${ROOT_DIR}/TOOLS/scripts/01_generate_yamls.sh" --profile "$PROFILE_DIR"
|
||||
"${ROOT_DIR}/TOOLS/scripts/02_generate_resources_and_docs_v2.sh" --profile "$PROFILE_DIR"
|
||||
|
||||
resolve_root_path() {
|
||||
local path_value="$1"
|
||||
if [[ -z "$path_value" ]]; then
|
||||
@@ -51,7 +54,13 @@ resolve_root_path() {
|
||||
echo "${ROOT_DIR}/${path_value}"
|
||||
}
|
||||
|
||||
S3CFG_REGISTRY="${S3CFG_REGISTRY:-${ROOT_DIR}/secrets/.s3cfg_registry}"
|
||||
if [[ -z "${S3CFG_REGISTRY:-}" ]]; then
|
||||
if [[ -f "${ROOT_DIR}/secrets/.s3cfg_provider" ]]; then
|
||||
S3CFG_REGISTRY="${ROOT_DIR}/secrets/.s3cfg_provider"
|
||||
else
|
||||
S3CFG_REGISTRY="${ROOT_DIR}/secrets/.s3cfg_registry"
|
||||
fi
|
||||
fi
|
||||
TIMEOUTS_SOURCE="${TIMEOUTS_SOURCE:-${PROFILE_DIR}/operation_timeouts.json}"
|
||||
PROFILE_RESOURCES_YAML_DIR="${ROOT_DIR}/generated/$(basename "$PROFILE_DIR")/resources_yaml"
|
||||
PROFILE_GENERATED_GO_DIR="${ROOT_DIR}/generated/$(basename "$PROFILE_DIR")/go"
|
||||
@@ -153,7 +162,7 @@ validate_registry_completeness() {
|
||||
tmp_registered="$(mktemp)"
|
||||
tmp_missing="$(mktemp)"
|
||||
|
||||
find "$resources_gen_dir" -maxdepth 1 -type f -name '*_resource.go' -print0 \
|
||||
find "$resources_gen_dir" -maxdepth 1 -type f \( -name '*_resource.go' -o -name '*_modifier.go' -o -name '*_action.go' \) -print0 \
|
||||
| xargs -0 grep -hE 'func[[:space:]]+New[[:alnum:]_]+Resource[[:space:]]*\(' \
|
||||
| sed -E 's/.*(New[[:alnum:]_]+Resource).*/\1/' \
|
||||
| sort -u > "$tmp_expected"
|
||||
|
||||
@@ -71,6 +71,16 @@ func main() {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for idx := range ops {
|
||||
if strings.EqualFold(ops[idx].Action, "modify") && svc.ID == 19 {
|
||||
ops[idx].Kind = "modifier"
|
||||
ops[idx].Modifier = "ip_space"
|
||||
}
|
||||
if strings.EqualFold(ops[idx].Action, "modify") && svc.ID == 22 {
|
||||
ops[idx].Kind = "modifier"
|
||||
ops[idx].Modifier = "network"
|
||||
}
|
||||
}
|
||||
|
||||
specYAML := types.ServiceSpec{
|
||||
Name: name,
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
| Стенд | Namespace | Версия | Дата заливки |
|
||||
|---|---|---|---|
|
||||
| PROD | `nubes` | `1.0.0` | 2026-09-03 | (новая нумерация) |
|
||||
| DEV | `nubes-dev` | `2.0.0` | 2026-09-03 | (новая нумерация) |
|
||||
| DEV | `nubes-dev` | `2.0.1` | 2026-09-20 | (добавлены parent modify/modifier ресурсы) |
|
||||
| TEST | `nubes-test` | `3.0.0` | 2026-09-03 | (новая нумерация) |
|
||||
|
||||
## Как проверить
|
||||
|
||||
@@ -43,6 +43,25 @@
|
||||
- `universal_rebuild/tools/gen/main.go`
|
||||
- Читает YAML и генерирует ресурсы + `registry.go`.
|
||||
|
||||
### 1.6 Отдельные modifier-ресурсы
|
||||
Для parent-level операций `modify`, которые должны выполняться отдельным шагом Terraform-цепочки, используется `kind: modifier`.
|
||||
|
||||
Пример:
|
||||
|
||||
```yaml
|
||||
- name: modify
|
||||
kind: modifier
|
||||
action: modify
|
||||
modifier: ip_space
|
||||
params: []
|
||||
```
|
||||
|
||||
Такой блок не попадает в обычный instance CRUD. Go-генератор создаёт отдельный ресурс с именем `nubes_<service>_<modifier>`. Ресурс принимает ID родительского инстанса и параметры операции, выполняет parent `modify` при Create/Update и читает актуальные значения из `state_params` при Read.
|
||||
|
||||
Для `vcOrg` используется modifier `ip_space` с параметром `vIPConfigure`; для `vcNsxt` используется modifier `network` с параметрами операции сетевой настройки. Nested API-параметры modifier-ресурсов передаются как JSON-строки, поэтому их Terraform-значения должны быть валидным JSON.
|
||||
|
||||
Удаление modifier пока является no-op: подтверждённого обратного payload для отмены выделенных IP или SNAT нет. Операции удаления родительского сервиса не являются rollback и намеренно не вызываются.
|
||||
|
||||
---
|
||||
|
||||
## 2) Как получить параметры сервиса (без instanceUid)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Резюме сессии: Диагностика K8s (Штурвал), VDC и архитектура провайдера (2026-09-20)
|
||||
|
||||
## 1. Инфраструктурный контекст
|
||||
- **ВМ 213 (jump-host / dev)**:
|
||||
- SSH: `ssh vps` (`5.172.178.213`, user `naeel`, key `~/.ssh/naeel_vm_id_ed25519`).
|
||||
- kubectl контекст по умолчанию: `tazetdinovn@gmail.com@naeel-test-3`.
|
||||
- **Кластер `naeel-test-3`**:
|
||||
- API: `https://185.247.187.146:6443`.
|
||||
- Узлы: 6 нод (3 control-plane, 3 workers), версия v1.34.1 / платформа Штурвал 2.12.1.
|
||||
- **Кластер `devclustername`**:
|
||||
- API: `https://185.247.187.226:6443`.
|
||||
- Статус: порт 6443 на Edge доступен, но TLS сбрасывается (`connection reset by peer`) — виртуальные машины кластера находятся в `suspend` / выключены.
|
||||
|
||||
---
|
||||
|
||||
## 2. Что было сделано и починено в кластере `naeel-test-3`
|
||||
|
||||
### Проблема:
|
||||
В веб-интерфейсе Штурвала кластер висел в статусе **«Работает с ошибками» (⚠️ 2/4 по NodeConfigItems)**.
|
||||
|
||||
### Причина:
|
||||
1. На активном воркере `naeel-test-3-workers-5p8w7-vxzch` висело ожидание применения конфигураций (`RebootPending` с типом `drainonly`).
|
||||
2. Очередь на применение/drain была заблокирована (`SlotsOccupied`), так как в CRD `nodeconfigs.node.shturval.tech` осталась старая удалённая нода `naeel-test-3-workers-5p8w7-nqkr2` с зависшим флагом `rebootallowed: true`.
|
||||
|
||||
### Решение:
|
||||
1. Вычищены все фантомные объекты `nodeconfigs`, которых уже нет среди реальных K8s-нод (сняты блокирующие finalizers).
|
||||
2. Слот освободился: контроллер `shturval-node-config` корректно выполнил `drainonly` на воркере `vxzch` (под `pythonk8s` переехал на соседний воркер `wwqj2`).
|
||||
3. Применились оставшиеся `NodeConfigItem` (`generic-init-config`, `all-to-nubes-registry`).
|
||||
4. **Текущий статус**:
|
||||
- Все 6 нод в статусе `Ready`.
|
||||
- Все 6 `nodeconfigs` в статусе `READY: true`.
|
||||
- Все 4 `nodeconfigitems` в статусе `ready: true` (**4/4, статус кластера зелёный**).
|
||||
- Под `pythonk8s` (`drhider.pythonk8s.dev.nubes.ru`, ns `20a75175-a58c-49cb-b8fa-e86367b1a8dc`) поднят и работает (1/1 Running).
|
||||
- Потребление ресурсов: среднее ~22m CPU и ~103 MiB RAM на под; суммарно на весь 6-узловой кластер ~1.6 CPU и ~7.5 GiB RAM (минимальный фоновый простой).
|
||||
|
||||
---
|
||||
|
||||
## 3. Блокировка операций в личном кабинете облака (Suspend / Modify)
|
||||
|
||||
### Симптом:
|
||||
При попытке выполнить операцию `suspend` кластера в UI облака возникает ошибка:
|
||||
`Concurrent operations are not supported (job status: cannot obtain job result) There is a started operation on this instance`
|
||||
|
||||
### Диагностика через API Gateway (`lk-api-gateway-dev.ngcloud.ru`):
|
||||
- Инстанс кластера: `e78a40b9-7de1-4c88-af7a-ad7f7efaa7c2`.
|
||||
- Зависшая операция: `modify` (`opUid: 2fb7dd59-732a-4bb9-add2-6bddd7329f72`).
|
||||
- Причина зависания: оркестратор CFS поймал таймаут (`Timeout has been exceeded`), выполнил откат, записал лог ошибки, но **не проставил `dtFinish` и `isSuccessful` в БД**.
|
||||
- Статус операции в API остался незакрытым, из-за чего API Gateway блокирует любые новые операции над инстансом.
|
||||
- **Внимание**: это баг бэкенда платформы облака (CFS/оркестратора). Средствами `kubectl` внутри кластера это не лечится — требуется сброс статуса операции на стороне API платформы или через поддержку.
|
||||
- Ошибка в истории операций: `Не удалось включить кластер. Ошибка: Error not found from 'parseTerraformError'` — вызвана тем, что общий обработчик бэкенда облака по ошибке прогнал текст через парсер ошибок Terraform. Внутри K8s Terraform не используется.
|
||||
|
||||
---
|
||||
|
||||
## 4. Архитектурные правила по VDC и Terraform-провайдеру
|
||||
|
||||
### Почему возникает ошибка дубликата VDC:
|
||||
`VDC с именем 'WZ03709-saas-snb1-i24-vcpu50' уже существует внутри организации 'WZ03709-saas'`
|
||||
- Имя VDC генерируется бэкендом детерминированно: `{org}-{type}-{segment}-{cpu}-vcpu{reservation}`.
|
||||
- В одном сегменте организации существует максимум два класса VDC:
|
||||
- `vcpu50` (50% гарантия vCPU — для dev/test, оверселлинг, дешевле);
|
||||
- `vcpu80` (80% гарантия vCPU — для prod/баз данных, жесткая фиксация ресурсов).
|
||||
- Создавать третий VDC с тем же процентом SLA в рамках одной организации невозможно (конфликт уникальности имен в vCloud) и бессмысленно: изоляция проектов внутри VDC делается через **vApp**, **сети (Org Networks)** и правила фаервола. При нехватке ресурсов VDC масштабируется через `modify`.
|
||||
- **Канон Terraform**: при работе с уже созданными VDC в манифесте указывать:
|
||||
```hcl
|
||||
adopt_existing_on_create = true
|
||||
```
|
||||
(согласно `docs/60_strategy/provider_philosophy.md`).
|
||||
|
||||
### Зачем нужны дочерние ресурсы-действия (Action / Sub-resources):
|
||||
Цепочка развертывания vCloud/NSX-T имеет циклические зависимости:
|
||||
1. `vcOrg -> create`
|
||||
2. `vcVdc -> create`
|
||||
3. `vcNsxt -> create` (Edge Gateway)
|
||||
4. `vcOrg -> modify` (выделение белых IP в организацию, так как появился Edge)
|
||||
5. `vcNsxt -> modify` (настройка SNAT под конкретный выделенный IP)
|
||||
6. `k8sShturval -> create` (нодам нужен интернет через SNAT)
|
||||
|
||||
Чтобы пользователю не приходилось делать несколько ручных прогонов `terraform apply` с ручным редактированием `.tf` файлов между шагами, в провайдере создаются **дочерние ресурсы-действия**.
|
||||
- Под капотом провайдера эти дочерние ресурсы транслируются в точечные вызовы **`modify`** над родительскими объектами.
|
||||
- Это стандартная практика в Terraform (аналог `aws_security_group_rule` для `aws_security_group`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Что делать дальше в новом чате
|
||||
1. Если продолжаем работу с провайдером Nubes:
|
||||
- Проверить статус зависшей операции `2fb7dd59-732a-4bb9-add2-6bddd7329f72` в API Gateway.
|
||||
- Разрабатывать/тестировать логику дочерних ресурсов (action/sub-resources) и `modify`-пайплайна по стандарту `docs/60_strategy/provider_philosophy.md`.
|
||||
2. Если требуется проверить второй кластер (`devclustername` / `185.247.187.226`):
|
||||
- Убедиться, что кластер выведен из suspend в веб-интерфейсе (чтобы поднялся API server на порту 6443).
|
||||
@@ -14,8 +14,68 @@
|
||||
## 2. Публикация провайдера в Registry
|
||||
- Артефакты: zip, SHA256SUMS, SHA256SUMS.sig
|
||||
- Подпись: для .sig использовать бинарную detached подпись
|
||||
- Хранилище: S3 bucket terraform-registry
|
||||
- Префикс: docs/<namespace>/<name>/<version>/ для документации, отдельный префикс для бинарников по правилам registry-сервера
|
||||
- Хранилище бинарников: S3 bucket `nubes-terraform-registry`
|
||||
- Хранилище документации: S3 bucket `terraform-registry` (public)
|
||||
- Префикс: `docs/<namespace>/<name>/` для документации, `<host>/<namespace>/<name>/<version>/` для бинарников
|
||||
|
||||
### Канонический provider release workflow
|
||||
|
||||
Рабочий registry для provider-бинарников использует:
|
||||
|
||||
- Registry hostname: `tf-registry.containerk8s.services.ngcloud.ru`;
|
||||
- S3 endpoint: `https://s3.msk-1.ngcloud.ru`;
|
||||
- S3 bucket: `nubes-terraform-registry`;
|
||||
- DEV namespace: `nubes-dev`;
|
||||
- Provider name: `nubes`.
|
||||
|
||||
Для DEV version `2.0.1` полный путь бинарников:
|
||||
|
||||
```text
|
||||
nubes-terraform-registry/tf-registry.containerk8s.services.ngcloud.ru/nubes-dev/nubes/2.0.1/
|
||||
```
|
||||
|
||||
Публикация выполняется из корня репозитория одной командой:
|
||||
|
||||
```bash
|
||||
cd /home/naeel/TF/tf_provider
|
||||
REQUEST_DELAY=0.05 ATTEMPTS=3 \
|
||||
./TOOLS/scripts/03_build_and_upload_provider.sh \
|
||||
--profile TOOLS/config/dev 2.0.1
|
||||
```
|
||||
|
||||
`03_build_and_upload_provider.sh` перед сборкой автоматически запускает `01` и `02`, поэтому отдельно запускать генераторы для обычного release не требуется. В результате создаются:
|
||||
|
||||
- `generated/dev/resources_yaml/` — актуальные YAML из DEV API;
|
||||
- `generated/dev/go/` — generated Go, включая `*_modifier.go` и `registry.go`;
|
||||
- `generated/dev/docs/` — generated docs;
|
||||
- `generated/dev/provider_build/` — три ZIP, `SHA256SUMS` и `SHA256SUMS.sig`.
|
||||
|
||||
Перед upload скрипт проверяет, что каждый constructor из resource/modifier/action-файлов присутствует в `registry.go`, затем собирает provider для `linux/amd64`, `windows/amd64` и `darwin/amd64`.
|
||||
|
||||
### Разделение S3-хранилищ и Credentials
|
||||
|
||||
В архитектуре используются **два разных бакета S3**:
|
||||
|
||||
1. **Бинарники провайдеров:**
|
||||
- Бакет: `nubes-terraform-registry`
|
||||
- Права на заливку: аккаунт `1112_terraform:super` (`secrets/.s3cfg_provider`, алиас `prod-s3`)
|
||||
- Чтение: реестр читает через встроенный аккаунт `1112_terraform:reader` (`6DDP5...`)
|
||||
2. **MkDocs-документация:**
|
||||
- Бакет: `terraform-registry` (публичный)
|
||||
- Права на заливку: аккаунт `1325` (`secrets/.s3cfg_registry`, алиас `registry`)
|
||||
|
||||
Нельзя путать эти бакеты: заливка бинарников в `terraform-registry` делает версию невидимой для реестра, а попытка залить бинарники ключом документации вызывает ошибку прав доступа (`Insufficient permissions`).
|
||||
|
||||
### Контроль успешной публикации
|
||||
|
||||
Успех подтверждается только после всех трёх условий:
|
||||
|
||||
1. В выводе есть `Done. Version <version> uploaded.` и exit code `0`.
|
||||
2. В `generated/<stand>/provider_build/` присутствуют ZIP, `SHA256SUMS` и `.sig`.
|
||||
3. Объекты видны в точном S3 prefix через `mc ls prod-s3/nubes-terraform-registry/...`.
|
||||
4. Запрос `curl https://tf-registry.containerk8s.services.ngcloud.ru/v1/providers/<namespace>/<name>/versions` возвращает новую версию в списке.
|
||||
|
||||
Если публикация прервалась во время `01`, повторный запуск безопасен: он только читает service specs из API и перезаписывает generated YAML. Terraform apply, modify и delete для публикации не запускаются.
|
||||
|
||||
### Важное про GPG ключи
|
||||
- Приватный ключ должен быть стабильным между релизами.
|
||||
|
||||
Reference in New Issue
Block a user