Skip to content

Compute

github-actions[bot] edited this page Oct 11, 2026 · 6 revisions

Compute

compute is danny.vn/vngcloud/compute, with its own New(cfg). It reads vServer instances and images; reads, creates, updates, and deletes server groups; and reads, imports, creates, and deletes SSH keys. Server writes (create, start, stop, reboot, rename, resize, and delete) are on Compute Servers.

Setup

package main

import (
	"context"
	"log"

	"danny.vn/vngcloud"
	"danny.vn/vngcloud/compute"
)

func main() {
	ctx := context.Background()

	cfg, err := vngcloud.NewConfig(
		vngcloud.WithRegion("hcm-3"),
		vngcloud.WithIAMUser(&vngcloud.IAMUserAuth{
			RootEmail: "<root-email>",
			Username:  "<iam-username>",
			Password:  "<password>",
		}),
	)
	if err != nil {
		log.Fatal(err)
	}

	client := compute.New(cfg)
	_ = ctx
	_ = client
}

The rest of this page assumes cfg and ctx from this setup, plus client := compute.New(cfg).

Reading

servers, err := client.ListServers(ctx, in)     // Page, Size
server, err := client.GetServer(ctx, in)        // ServerID (required)
keys, err := client.ListSSHKeys(ctx, in)        // Name, Page, Size
key, err := client.GetSSHKey(ctx, in)           // SSHKeyID (required)
groups, err := client.ListServerGroups(ctx, in) // Name, Page, Size
group, err := client.GetServerGroup(ctx, in)    // ServerGroupID (required)
osImages, err := client.ListOSImages(ctx, in)   // ZoneID
flavors, err := client.ListFlavors(ctx, in)     // FlavorZoneID or ZoneID, Name
gpuImages, err := client.ListGPUImages(ctx, nil)
userImages, err := client.ListUserImages(ctx, in) // Page, Size

ListServerSecurityGroups and ListServerGroupMembers flatten nested data already returned by ListServers and ListServerGroups; see Services for the full read method list. ListServerGroups' own Name filter matches by substring, not exactly: a search for "web" also finds "webhook". Any code that must find one group by name lists and scans for an exact match itself.

Listing flavors

Set exactly one of FlavorZoneID and ZoneID; neither, or both, fails with vngcloud.ErrInvalidInput before any request. Name keeps only flavors whose name equals it exactly.

flavors, err := client.ListFlavors(ctx, &compute.ListFlavorsInput{
	ZoneID: "<zone-id>",
	Name:   "s2-general-1x2",
})
if err != nil {
	log.Fatal(err)
}
for _, f := range flavors.Items {
	log.Println(f.FlavorID, f.FlavorZoneID, f.IsSoldOut)
}

With ZoneID, the SDK lists the flavor zones in that network zone, then the flavors of each one at a time: 1 + N requests for N flavor zones. Rows follow the flavor zone order, then the API's order inside each flavor zone. Each row carries its FlavorZoneID; the SDK fills it when the API leaves it empty. A flavor zone with no flavors adds no rows, a flavor listed in two flavor zones stays two rows, and sold-out flavors stay in the list with IsSoldOut true. The first failing request ends the call with its error and no rows.

Monthly flavor prices

ListFlavorPrices compares the instance component of a reference server:

prices, err := client.ListFlavorPrices(ctx, &compute.ListFlavorPricesInput{
 Family:      "general-purpose",
 Platform:    "code-s2",
 Name:        "s2-general-1x2",
 MaxRequests: 100,
})

Nil input uses defaults. ZoneID selects one returned zone; empty selects all zones in the configured region. Family, Platform, and Name match exactly, with case preserved. Unknown filters return an empty complete list. An unknown explicit zone fails with vngcloud.ErrInvalidInput. MinRAM and MaxRAM bound raw integer memory; zero means unbounded. Negative or conflicting bounds fail before requests. RAM's byte unit is unverified: RAMUnit is unknown, and RAMGiB and PricePerGiB remain nil.

Each FlavorPrice preserves the catalog Flavor, family, platform, zone, and visibility. Family types do not establish a flavor-to-type join. Disabled zones remain inapplicable without quotes. Sold-out rows remain visible unless AvailableOnly requests rows that are not sold out and have positive remaining VMs. Catalog visibility and remaining VMs promise no sale eligibility or future capacity. IncludeNonPublic reads product and custom catalogs; populated custom and GPU branches remain gated on matching fixtures. Conflicting legacy zone IDs fail rather than being reassigned. ExcludedGroups remains nil when the product catalog was not read.

Reference records a supported unlicensed Ubuntu image and a bounded 3000 IOPS root disk. Image support must match the flavor's metadata exactly. Missing or unusable support, licence, or package limits cannot justify a reference. Eligible pairs sort by root size, then bytewise image version, image ID, and disk type ID. Image-zone metadata does not prove compatibility; the quote validates the pair. No candidate leaves Status=unknown and nil reference. A refused reference fails without trying another candidate. Discovery never returns image licence keys.

MonthlyPrice comes only from the matching positive INSTANCE TYPE line, with Basis=instance-type-line, VND, and tax as returned. Root totals, data disks, CES, licensing, IPs, bandwidth, traffic, snapshots, backups, and NAT are excluded. PricePerRAMUnit divides that component by positive raw memory. Original and discount totals stay nil; a present instance-line discount percentage is kept. Use server quotes for a full server.

Output contains Items and account-scoped Metadata. SDK rows sort by zone, family, platform, raw RAM, CPU, and flavor ID. QuotedAt is UTC completion time. The default MaxRequests is 500, with range 1..2000. Catalog reads count too; preflight refuses the entire quote phase when distinct bodies cannot fit. Requests run serially at least 500 ms apart with a ten-minute child deadline. Shared authentication or project waits may outlast the deadline; cancellation stops work after the wait ends. Catalogs and successful quotes are reused only within one invocation.

Without AllowPartial, a failure returns nil output and the original error. With AllowPartial, independently validated rows survive and the error matches pricing.ErrDiscoveryIncomplete. Failures before any usable row still return nil and the cause. Authentication failure, exhausted throttling, and cancellation stop scheduling. errors.As retains the underlying *vngcloud.APIError.

vngcloud --region hcm-3 --read-only compute list-flavor-prices \
  --family general-purpose --platform code-s2 --name s2-general-1x2 \
  --max-requests 100 --sort-by unit-price

The command supports --sort-by name, ram, monthly-price, and unit-price, plus --descending. Unit-price sorts use raw RAM rates; unknown numeric values sort last in either direction. Sorting runs before --query. JSON preserves Items, Metadata, and nulls. Table/text show reference inputs and unknown GiB rates; notes print on stderr. Incomplete partial output prints before the error and exits 1 even when a query hides metadata. Complete discovery exits 0.

Creating, updating, and deleting server groups

policies, err := client.ListServerGroupPolicies(ctx, nil)
if err != nil {
	log.Fatal(err)
}

created, err := client.CreateServerGroup(ctx, &compute.CreateServerGroupInput{
	Name:     "web-tier",
	PolicyID: policies.Items[0].UUID,
})
if err != nil {
	log.Fatal(err)
}
log.Println(created.ServerGroup.UUID)

updated, err := client.UpdateServerGroup(ctx, &compute.UpdateServerGroupInput{
	ServerGroupID: created.ServerGroup.UUID,
	Description:   vngcloud.Ptr("web tier, edge servers"),
})
if err != nil {
	log.Fatal(err)
}
log.Println(updated.ServerGroup.Description)

if _, err := client.DeleteServerGroup(ctx, &compute.DeleteServerGroupInput{
	ServerGroupID: created.ServerGroup.UUID,
}); err != nil {
	log.Fatal(err)
}

A group's policy is set at create and cannot change; ListServerGroupPolicies lists the choices. Server group names are unique per project; a repeat name fails with the server's own message.

CreateServerGroup is a POST and is never retried after a failure that may already have reached the server: after any error that is not a 4xx *vngcloud.APIError, the group may exist. List server groups and match the name exactly before creating it again, rather than retrying blind. A create is synchronous: the response already carries the finished group, so there is no wait.

UpdateServerGroup changes Name, Description, or both; at least one must be set, and a Name that is set must not be the empty string, or the call fails with vngcloud.ErrInvalidInput and sends nothing. The API takes a full replacement body, so the SDK reads the group first and resends whichever field the caller left nil unchanged, plus a serverGroupId field set to the same id as the path (the PUT fails without it), then reads the group once more after the PUT to build the Output from a shape the SDK trusts rather than the PUT response itself. If that confirm read fails, the write has already succeeded: the error wraps compute.ErrNotSettled. Unlike CreateServerGroup's POST, this update is a read-merge PUT and is safe to run again after ErrNotSettled; it will just resend the same Name and Description. The Output falls back to the fields the PUT itself sent. There is no PolicyID field on the update; a group's policy cannot change after create.

DeleteServerGroup lists server groups first, since GetServerGroup never returns a group's members, and sends nothing when that list shows the group has any server attached (compute.ErrServerGroupInUse). A group can be in use by more than servers, so the server's own refusal is the final guard: an error whose message contains "server group is in use" also wraps compute.ErrServerGroupInUse, whatever its HTTP status. DELETE keeps the transport's normal retries; a retry sent after the first response was lost can find the group already gone and get back a 404, which vngcloud.IsNotFound(err) == true reports the same as any other unknown id. That 404 means the group is gone, not that the delete failed.

GetServerGroup on an unknown id gets a 200 with "data" null rather than a 404; GetServerGroup treats that the same way, returning vngcloud.IsNotFound(err) == true instead of an empty ServerGroup.

If a group is managed by OpenTofu or Terraform, a write made here drifts from that state; keep such a group's writes in its own tool.

SSH keys

An SSH key is either imported from a public key made elsewhere, or created by having GreenNode generate the key pair and hand back the private key once. Prefer ImportSSHKey: generate a key pair yourself, such as with ssh-keygen -t rsa -b 3072, and import only the public half, so the private key never leaves your machine. Use CreateSSHKey only when you need GreenNode to generate the key pair itself; in that case GreenNode has generated and seen the private key.

The server accepts RSA public keys (ssh-rsa) only. ed25519 (ssh-ed25519) and ECDSA (ecdsa-sha2-nistp256) keys are refused with 400 "Invalid public key".

imported, err := client.ImportSSHKey(ctx, &compute.ImportSSHKeyInput{
	Name:      "laptop",
	PublicKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQ... me@laptop",
})
if err != nil {
	log.Fatal(err)
}
log.Println(imported.SSHKey.ID)

created, err := client.CreateSSHKey(ctx, &compute.CreateSSHKeyInput{Name: "generated"})
if err != nil {
	log.Fatal(err)
}
log.Println(created.SSHKey.ID)
privateKeyPEM := created.PrivateKey.Reveal() // an OpenSSH private key, PEM-encoded

if _, err := client.DeleteSSHKey(ctx, &compute.DeleteSSHKeyInput{
	SSHKeyID: created.SSHKey.ID,
}); err != nil {
	log.Fatal(err)
}

ImportSSHKey's PublicKey must be one line after trimming leading and trailing whitespace, and must not contain the text PRIVATE KEY; both checks run before any request, and neither one's error ever repeats the value back. Key type and size are not checked by the SDK; the server decides what it accepts, and currently accepts RSA keys only.

SSHKey has no field for a private key, on any call: a read can never carry one back, matching what GreenNode's API itself returns. Only CreateSSHKeyOutput carries one, as PrivateKey, separate from SSHKey. It is an OpenSSH private key, PEM-encoded starting -----BEGIN OPENSSH PRIVATE KEY-----, not a PKCS#1 or PKCS#8 key.

Both ImportSSHKey and CreateSSHKey are POST and are never retried after a failure that may have already reached the server: after any error that is not a 4xx *vngcloud.APIError, the key may exist. Call ListSSHKeys with Name and look for it before calling either one again, rather than retrying blind; a key found that way after a failed CreateSSHKey has already lost its private key and should be deleted.

DeleteSSHKey is idempotent. A retry that finds the key already gone gets a 400 "Cannot get ssh key with id <id>" from the server, not a 404; DeleteSSHKey recognizes that message and returns vngcloud.IsNotFound(err) == true for it, same as any other unknown-id response. GetSSHKey on an unknown id gets a 200 with an empty object rather than a 404 or 400; GetSSHKey treats that the same way, returning vngcloud.IsNotFound(err) == true instead of an empty SSHKey.

The private key is a Secret

CreateSSHKeyOutput.PrivateKey is a vngcloud.Secret, not a plain string. Printing it with an ordinary fmt verb (%v, %+v, %#v, %s, and so on), logging it through log/slog, or encoding it with json.Marshal all give "[redacted]"; this holds even when the Secret is a field of a larger struct, such as the whole CreateSSHKeyOutput. Reveal() is the only way to read the actual value back out:

fmt.Println(created)               // ...PrivateKey:[redacted]
fmt.Printf("%+v\n", created)        // same
log.Println(created.PrivateKey)     // [redacted]
data, _ := json.Marshal(created)    // {"SSHKey":{...},"PrivateKey":"[redacted]"}

key := created.PrivateKey.Reveal()  // the private key, in PEM form

Three things bypass the redaction, none of them through the methods above: fmt.Printf("%p", ...) on a Secret prints the value itself, in fmt's own error text for a verb it does not support (%!p(vngcloud.Secret=...)); encoding/gob encodes the underlying string directly; and reflect.Value.String() called on a Secret value returns the string itself rather than calling String(). Never format a Secret with %p, gob-encode one, or read one back with reflect.Value.String().

Nothing in the SDK writes a Secret's value to a file for you; save Reveal()'s result yourself, with a mode that keeps it readable only by you (0600 on Unix), and never print or log it once read.

Errors

A malformed SSHKeyID, ServerGroupID, or PolicyID, an empty required field, an empty UpdateServerGroup input, an UpdateServerGroup Name set to the empty string, or an ImportSSHKey shape refusal fails with vngcloud.ErrInvalidInput before any request. An unknown key or server group fails with vngcloud.IsNotFound(err) == true. Server write errors (ErrFailed, ErrUnexpectedStatus, and ErrNotSettled for CreateServer, StartServer, StopServer, RebootServer, ResizeServer, and DeleteServer) are on Compute Servers.

var ErrServerGroupInUse = errors.New("compute: server group in use")
var ErrNotSettled       = errors.New("compute: write accepted but not settled")

ErrServerGroupInUse means DeleteServerGroup sent nothing because the group has servers attached, or that its DELETE request was refused by the server; see Creating, updating, and deleting server groups above. ErrNotSettled means UpdateServerGroup's confirm read after a successful PUT failed to come back; the write already reached the server, but the update is safe to run again, since it is a read-merge PUT that resends both fields either way.

A duplicate name, a quota, or a rejected key or policy shape from the server itself comes back as the server's own *vngcloud.APIError; see Errors for the general error model.

Clone this wiki locally