Skip to content
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 go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ module github.com/stackshy/cloudemu/v2
go 1.25.0

require (
cloud.google.com/go/artifactregistry v1.20.0
cloud.google.com/go/compute v1.60.0
cloud.google.com/go/eventarc v1.18.0
cloud.google.com/go/firestore v1.22.0
cloud.google.com/go/storage v1.62.1
github.com/Azure/azure-sdk-for-go/sdk/ai/azopenai v0.7.2
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/artifactregistry v1.20.0 h1:j/XQiQfaeTyQeNj3HNk4iDFREVnY/fxkHIjsxpaDs8A=
cloud.google.com/go/artifactregistry v1.20.0/go.mod h1:0G9wdbGyDFkvrYH+2AlQs9MuTJdbY8Vg45M8VjlI8rc=
cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ=
cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
Expand All @@ -10,6 +12,8 @@ cloud.google.com/go/compute v1.60.0 h1:CqGt23ysz990ZZe1vq/9aDPKKnmwM6kcC7Y1Q05H2
cloud.google.com/go/compute v1.60.0/go.mod h1:Xm6PbsLgBpAg4va77ljbBdpMjzuU+uPp5Ze2dnZq7lw=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/eventarc v1.18.0 h1:8WWG1/ogInYur1NQjML6EMHQ0ZBzAdMDGlUVpLD56cI=
cloud.google.com/go/eventarc v1.18.0/go.mod h1:/6SDoqh5+9QNUqCX4/oQcJVK16fG/snHBSXu7lrJtO8=
cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E=
cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU=
cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U=
Expand Down
23 changes: 21 additions & 2 deletions providers/gcp/gce/gce.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,20 @@ func (m *Mock) TerminateInstances(ctx context.Context, instanceIDs []string) err
return m.transitionInstances(ctx, instanceIDs, terminateTransition)
}

// RemoveInstance hard-deletes an instance, mirroring GCP's instances.delete
// (which removes the resource, unlike EC2 terminate which leaves a TERMINATED
// tombstone). GCP-specific; reached via a type assertion from the GCE handler.
func (m *Mock) RemoveInstance(_ context.Context, instanceID string) error {
if !m.instances.Has(instanceID) {
return cerrors.Newf(cerrors.NotFound, "instance %q not found", instanceID)
}

m.instances.Delete(instanceID)
m.sm.Remove(instanceID)

return nil
}

func (m *Mock) DescribeInstances(
_ context.Context, instanceIDs []string, filters []driver.DescribeFilter, _ ...driver.DescribeInstancesOptions,
) ([]driver.Instance, error) {
Expand Down Expand Up @@ -514,8 +528,13 @@ func (m *Mock) DescribeSnapshots(_ context.Context, ids []string) ([]driver.Snap
}

func (m *Mock) CreateImage(_ context.Context, cfg driver.ImageConfig) (*driver.ImageInfo, error) {
if _, ok := m.instances.Get(cfg.InstanceID); !ok {
return nil, cerrors.Newf(cerrors.NotFound, "instance %q not found", cfg.InstanceID)
// GCP images are created from a disk, snapshot, or import — not from a
// source instance. An empty InstanceID is one of those source-based paths,
// so only validate when a specific instance was named (the EC2-style path).
if cfg.InstanceID != "" {
if _, ok := m.instances.Get(cfg.InstanceID); !ok {
return nil, cerrors.Newf(cerrors.NotFound, "instance %q not found", cfg.InstanceID)
}
}

id := fmt.Sprintf("projects/%s/global/images/img-%d",
Expand Down
10 changes: 10 additions & 0 deletions providers/gcp/gke/gke.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ type Cluster struct {
IPRotationActive bool
NodePoolNames []string
Status string
MasterVersion string
NodeVersion string
CreatedAt time.Time
}

Expand Down Expand Up @@ -391,6 +393,14 @@ func (m *Mock) UpdateCluster(
if input.ResourceLabels != nil {
c.ResourceLabels = copyLabels(input.ResourceLabels)
}

if input.MasterVersion != "" {
c.MasterVersion = input.MasterVersion
}

if input.NodeVersion != "" {
c.NodeVersion = input.NodeVersion
}
})
}

Expand Down
30 changes: 16 additions & 14 deletions providers/gcp/secretmanager/secretmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,20 +65,22 @@ func (m *Mock) CreateSecret(_ context.Context, cfg driver.SecretConfig, value []
Tags: tags,
}

data := make([]byte, len(value))
copy(data, value)

versionID := idgen.GenerateID("ver-")
version := driver.SecretVersion{
VersionID: versionID,
Value: data,
CreatedAt: now,
Current: true,
}

sd := &secretData{
info: info,
versions: []driver.SecretVersion{version},
sd := &secretData{info: info}

// GCP's secrets.create makes an empty container — the first version is added
// separately via addVersion. Only seed a version when a value is actually
// supplied (the AWS-style create-with-value path); otherwise the secret has
// zero versions and access(latest) fails until one is added, matching GCP.
if len(value) > 0 {
data := make([]byte, len(value))
copy(data, value)

sd.versions = []driver.SecretVersion{{
VersionID: idgen.GenerateID("ver-"),
Value: data,
CreatedAt: now,
Current: true,
}}
}

m.secrets.Set(cfg.Name, sd)
Expand Down
26 changes: 24 additions & 2 deletions server/gcp/alloydb/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ func (*Handler) toWireCluster(c *rdsdriver.Cluster, info *rdsdriver.AlloyDBClust
DatabaseVersion: info.DatabaseVersion,
Network: info.Network,
ClusterType: info.ClusterType,
State: "READY",
State: alloyDBState(c.State),
Uid: c.ID,
ContinuousBackupConfig: &alloydb.ContinuousBackupConfig{
Enabled: info.ContinuousBackup,
Expand All @@ -125,12 +125,34 @@ func (*Handler) toWireInstance(inst *rdsdriver.Instance, info *rdsdriver.AlloyDB
AvailabilityType: info.AvailabilityType,
IpAddress: info.IPAddress,
GceZone: info.GceZone,
State: "READY",
State: alloyDBState(inst.State),
Uid: inst.ID,
MachineConfig: &alloydb.MachineConfig{CpuCount: int64(info.CPUCount)},
}
}

// alloyDBState maps the relationaldb driver's lifecycle state to AlloyDB's
// wire state enum, so a just-created or stopped resource reports its real
// state instead of always "READY".
const stateReady = "READY"

func alloyDBState(driverState string) string {
switch driverState {
case rdsdriver.StateAvailable, "":
return stateReady
case rdsdriver.StateCreating, rdsdriver.StateStarting:
return "CREATING"
case rdsdriver.StateDeleting:
return "DELETING"
case rdsdriver.StateStopped, rdsdriver.StateStopping:
return "STOPPED"
case rdsdriver.StateModifying, rdsdriver.StateRebooting, rdsdriver.StateBackingUp:
return "MAINTENANCE"
default:
return stateReady
}
}

func toWireBackup(s *rdsdriver.ClusterSnapshot, backupType string) *alloydb.Backup {
return &alloydb.Backup{
Name: s.ARN,
Expand Down
57 changes: 57 additions & 0 deletions server/gcp/artifactregistry/gapic_lro_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package artifactregistry_test

import (
"context"
"net/http/httptest"
"testing"

artifactregistry "cloud.google.com/go/artifactregistry/apiv1"
"cloud.google.com/go/artifactregistry/apiv1/artifactregistrypb"
"google.golang.org/api/option"

"github.com/stackshy/cloudemu/v2"
gcpserver "github.com/stackshy/cloudemu/v2/server/gcp"
)

// TestGAPICCreateRepositoryWait is the review's #3 check: the finding targeted
// the GAPIC apiv1 client's LRO .Wait(), which the raw google.golang.org/api
// REST client never exercised. This drives the real apiv1 REST client end to
// end — CreateRepository(...).Wait() must resolve (not 404, and not a decode
// error from a missing response @type) and return the created repository.
func TestGAPICCreateRepositoryWait(t *testing.T) {
cloud := cloudemu.NewGCP()
srv := gcpserver.New(gcpserver.DriversFrom(cloud)) // full server: exercises real dispatch
ts := httptest.NewServer(srv)
t.Cleanup(ts.Close)

ctx := context.Background()

client, err := artifactregistry.NewRESTClient(ctx,
option.WithEndpoint(ts.URL),
option.WithoutAuthentication(),
option.WithHTTPClient(ts.Client()),
)
if err != nil {
t.Fatalf("NewRESTClient: %v", err)
}

t.Cleanup(func() { _ = client.Close() })

op, err := client.CreateRepository(ctx, &artifactregistrypb.CreateRepositoryRequest{
Parent: "projects/demo/locations/us",
RepositoryId: "gapic-repo",
Repository: &artifactregistrypb.Repository{Description: "gapic"},
})
if err != nil {
t.Fatalf("CreateRepository: %v", err)
}

repo, err := op.Wait(ctx)
if err != nil {
t.Fatalf("op.Wait (the #3 GAPIC LRO fix): %v", err)
}

if repo == nil || repo.GetName() == "" {
t.Fatalf("Wait returned no repository: %+v", repo)
}
}
32 changes: 30 additions & 2 deletions server/gcp/artifactregistry/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const (
pathPrefix = "/v1/projects/"
locationsSeg = "locations"
repositoriesSeg = "repositories"
operationsSeg = "operations"
dockerImagesSeg = "dockerImages"
)

Expand All @@ -49,6 +50,7 @@ type route struct {
location string
repository string // repo id; empty for the collection
sub string // "dockerImages" or ""
operation string // operation id when this is an /operations/{op} path
}

// parseRoute extracts the components of an Artifact Registry v1 path.
Expand All @@ -58,14 +60,28 @@ func parseRoute(urlPath string) (route, bool) {
}

parts := strings.Split(strings.TrimPrefix(urlPath, "/v1/"), "/")
// parts: [projects, {p}, locations, {l}, repositories, {id}?, {sub}?]
// parts: [projects, {p}, locations, {l}, {repositories|operations}, {id}?, {sub}?]
if len(parts) < minRepoCollectionParts ||
parts[0] != "projects" || parts[2] != locationsSeg || parts[4] != repositoriesSeg {
parts[0] != "projects" || parts[2] != locationsSeg {
return route{}, false
}

rt := route{project: parts[1], location: parts[3]}

// LRO polling: GAPIC clients (.Wait()) GET the operation returned by a
// create/delete. Without this route those polls 404.
if parts[4] == operationsSeg {
if len(parts) > minRepoCollectionParts {
rt.operation = parts[5]
}

return rt, true
}

if parts[4] != repositoriesSeg {
return route{}, false
}

if len(parts) > minRepoCollectionParts {
rt.repository = parts[5]
}
Expand Down Expand Up @@ -93,6 +109,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}

if rt.operation != "" {
// The mock completes operations synchronously, so any poll resolves to
// a done operation. This unblocks GAPIC .Wait() callers. Echo the exact
// operation name that was polled.
gcprest.WriteJSON(w, http.StatusOK, operationJSON{
Name: "projects/" + rt.project + "/locations/" + rt.location + "/operations/" + rt.operation,
Done: true,
})

return
}

if rt.repository == "" {
h.serveCollection(w, r, &rt)
return
Expand Down
44 changes: 42 additions & 2 deletions server/gcp/artifactregistry/operations.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package artifactregistry

import (
"encoding/json"
"net/http"

"github.com/stackshy/cloudemu/v2/server/wire/gcprest"
Expand All @@ -15,16 +16,55 @@ func (h *Handler) createRepository(w http.ResponseWriter, r *http.Request, rt *r
return
}

// The driver models a Docker registry and has no format/description field,
// so preserve the request's values in reserved tags and echo them on read.
tags := make(map[string]string, len(body.Labels))
for k, v := range body.Labels {
tags[k] = v
}

if body.Format != "" {
tags[formatTag] = body.Format
}

if body.Description != "" {
tags[descriptionTag] = body.Description
}

repo, err := h.registry.CreateRepository(r.Context(), crdriver.RepositoryConfig{
Name: repoID,
Tags: body.Labels,
Tags: tags,
})
if err != nil {
gcprest.WriteCErr(w, err)
return
}

gcprest.WriteJSON(w, http.StatusOK, doneOperation(rt, repoID, toRepositoryJSON(rt.project, rt.location, repo)))
gcprest.WriteJSON(w, http.StatusOK, doneOperation(rt, repoID,
typedResponse(repositoryTypeURL, toRepositoryJSON(rt.project, rt.location, repo))))
}

// repositoryTypeURL is the protobuf Any type URL a GAPIC client expects in a
// done LRO's response so CreateRepositoryOperation.Wait() can decode it.
const repositoryTypeURL = "type.googleapis.com/google.devtools.artifactregistry.v1.Repository"

// typedResponse renders v as the JSON object a google.protobuf.Any expects: the
// resource's fields plus an "@type" URL. Without @type a GAPIC .Wait() cannot
// unmarshal the operation response.
func typedResponse(typeURL string, v any) map[string]any {
b, err := json.Marshal(v)
if err != nil {
return nil
}

m := map[string]any{}
if err := json.Unmarshal(b, &m); err != nil {
return nil
}

m["@type"] = typeURL

return m
}

func (h *Handler) getRepository(w http.ResponseWriter, r *http.Request, rt *route) {
Expand Down
Loading
Loading