diff --git a/examples/data-sources/nullplatform_artifact/data-source.tf b/examples/data-sources/nullplatform_artifact/data-source.tf new file mode 100644 index 0000000..cc4df5c --- /dev/null +++ b/examples/data-sources/nullplatform_artifact/data-source.tf @@ -0,0 +1,23 @@ +# Resolve a git repository artifact revision by url + reference — the ids are +# ready to pin in a package BOM. +data "nullplatform_artifact" "scopes_at_1_10" { + nrn = "organization=1255165411:account=95118862" + type = "git_repository" + meta = jsonencode({ + url = "https://github.com/nullplatform/scopes.git" + reference = "1.10.0" + }) +} + +# Identity-only lookup: resolves the artifact and its latest revision. +data "nullplatform_artifact" "scopes_latest" { + nrn = "organization=1255165411:account=95118862" + type = "git_repository" + meta = jsonencode({ + url = "https://github.com/nullplatform/scopes.git" + }) +} + +output "scopes_revision_id" { + value = data.nullplatform_artifact.scopes_at_1_10.revision_id +} diff --git a/examples/data-sources/nullplatform_package/data-source.tf b/examples/data-sources/nullplatform_package/data-source.tf new file mode 100644 index 0000000..e21b20a --- /dev/null +++ b/examples/data-sources/nullplatform_package/data-source.tf @@ -0,0 +1,16 @@ +# Look up a package by (nrn, slug); revision_id follows the package default. +data "nullplatform_package" "k8s_containers" { + nrn = "organization=1255165411:account=95118862" + slug = "k8s-containers" +} + +# Pin an exact published version instead. +data "nullplatform_package" "k8s_containers_v1" { + nrn = "organization=1255165411:account=95118862" + slug = "k8s-containers" + version = "1.0.0" +} + +output "default_revision_id" { + value = data.nullplatform_package.k8s_containers.revision_id +} diff --git a/examples/resources/nullplatform_artifact/resource.tf b/examples/resources/nullplatform_artifact/resource.tf new file mode 100644 index 0000000..8505c92 --- /dev/null +++ b/examples/resources/nullplatform_artifact/resource.tf @@ -0,0 +1,26 @@ +# Register a git repository at a specific reference (commit sha, tag or +# branch). The resource id is the revision id — ready to pin in a package BOM. +resource "nullplatform_artifact" "scopes_source" { + nrn = "organization=1255165411:account=95118862" + type = "git_repository" + meta = jsonencode({ + url = "https://github.com/nullplatform/scopes.git" + reference = "1.10.0" + }) +} + +# Register an OCI image by digest, shared with another organization scope. +resource "nullplatform_artifact" "runtime_image" { + nrn = "organization=1255165411:account=95118862" + type = "oci_image" + meta = jsonencode({ + registry = "ghcr.io" + repository = "nullplatform/runtime" + digest = "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + }) + + visible_to = [ + "organization=1255165411:account=95118862", + "organization=1255165411:account=12345", + ] +} diff --git a/examples/resources/nullplatform_package/resource.tf b/examples/resources/nullplatform_package/resource.tf new file mode 100644 index 0000000..098d23c --- /dev/null +++ b/examples/resources/nullplatform_package/resource.tf @@ -0,0 +1,48 @@ +# Publish a package pinning a service specification snapshot and an artifact +# revision. Bump `version` together with component changes to publish a new +# revision; `default = true` promotes each publish to the package default. +resource "nullplatform_package" "k8s_containers" { + nrn = "organization=1255165411:account=95118862" + slug = "k8s-containers" + name = "Containers" + version = "1.0.0" + default = true + + components { + name = "spec" + resource_type = "service_specification" + resource_id = nullplatform_service_specification.containers.id + resource_revision_id = var.containers_spec_snapshot_id + } + + components { + name = "source" + resource_type = "artifact" + resource_id = nullplatform_artifact.scopes_source.artifact_id + resource_revision_id = nullplatform_artifact.scopes_source.id + } + + visible_to = [ + "organization=1255165411", + ] +} + +# Pin the default to an exact published version instead of promoting each +# publish (mutually exclusive with `default = true`). Handy for staged +# rollouts and rollbacks: point default_version back at a previous release +# and apply. +resource "nullplatform_package" "pinned" { + nrn = "organization=1255165411:account=95118862" + slug = "pinned-runtime" + name = "Pinned Runtime" + version = "1.1.0" + + default_version = "1.0.0" # consumers stay on 1.0.0 while 1.1.0 soaks + + components { + name = "spec" + resource_type = "service_specification" + resource_id = nullplatform_service_specification.containers.id + resource_revision_id = var.containers_spec_snapshot_id + } +} diff --git a/nullplatform/data_source_package.go b/nullplatform/data_source_package.go new file mode 100644 index 0000000..21e6627 --- /dev/null +++ b/nullplatform/data_source_package.go @@ -0,0 +1,131 @@ +package nullplatform + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourcePackage() *schema.Resource { + return &schema.Resource{ + Description: "Looks up an existing nullplatform package by its natural key (nrn, slug). " + + "Optionally resolves a specific published version to its revision id; otherwise " + + "`revision_id` follows the package default (falling back to latest). The revision ids " + + "are what services bind to and what package-in-package composition will pin.", + ReadContext: dataSourcePackageRead, + Schema: map[string]*schema.Schema{ + "nrn": { + Type: schema.TypeString, + Required: true, + Description: "The owner NRN of the package.", + }, + "slug": { + Type: schema.TypeString, + Required: true, + Description: "The package slug, unique per NRN.", + }, + "version": { + Type: schema.TypeString, + Optional: true, + Description: "Resolve this exact published semver to `revision_id`. When omitted, `revision_id` follows the package default.", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "Human-readable display name.", + }, + "visible_to": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "NRNs allowed to consume this package.", + }, + "revision_id": { + Type: schema.TypeString, + Computed: true, + Description: "Revision UUID for `version` when set; otherwise the default (or latest) revision.", + }, + "default_revision_id": { + Type: schema.TypeString, + Computed: true, + Description: "Revision UUID services bind to by default.", + }, + "latest_revision_id": { + Type: schema.TypeString, + Computed: true, + Description: "Highest-semver revision UUID.", + }, + "default_version": { + Type: schema.TypeString, + Computed: true, + Description: "Semver of the default revision.", + }, + "latest_version": { + Type: schema.TypeString, + Computed: true, + Description: "Semver of the latest revision.", + }, + }, + } +} + +func dataSourcePackageRead(_ context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + nullOps := m.(NullOps) + + nrn := d.Get("nrn").(string) + slug := d.Get("slug").(string) + + pkg, err := nullOps.FindPackage(nrn, slug) + if err != nil { + return diag.FromErr(err) + } + + revisionID := pkg.DefaultRevisionID + if revisionID == "" { + revisionID = pkg.LatestRevisionID + } + + if version, ok := d.GetOk("version"); ok { + revisions, err := nullOps.ListPackageRevisions(pkg.ID) + if err != nil { + return diag.FromErr(err) + } + revisionID = "" + for _, revision := range revisions { + if revision.Version == version.(string) { + revisionID = revision.ID + break + } + } + if revisionID == "" { + return diag.FromErr(fmt.Errorf("package %s/%s has no published version %s", nrn, slug, version)) + } + } + + d.SetId(pkg.ID) + if err := d.Set("name", pkg.Name); err != nil { + return diag.FromErr(err) + } + if err := d.Set("visible_to", pkg.VisibleTo); err != nil { + return diag.FromErr(err) + } + if err := d.Set("revision_id", revisionID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("default_revision_id", pkg.DefaultRevisionID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("latest_revision_id", pkg.LatestRevisionID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("default_version", pkg.DefaultVersion); err != nil { + return diag.FromErr(err) + } + if err := d.Set("latest_version", pkg.LatestVersion); err != nil { + return diag.FromErr(err) + } + + return nil +} diff --git a/nullplatform/data_source_platform_artifact.go b/nullplatform/data_source_platform_artifact.go new file mode 100644 index 0000000..db2590b --- /dev/null +++ b/nullplatform/data_source_platform_artifact.go @@ -0,0 +1,171 @@ +package nullplatform + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func dataSourcePlatformArtifact() *schema.Resource { + return &schema.Resource{ + Description: "Looks up an existing platform artifact (and one of its revisions) by its " + + "meta fields — e.g. a git_repository by { url, reference } or an OCI image by " + + "{ registry, repository, digest }. Identity fields select the artifact; when " + + "per-revision fields are included the matching revision is resolved, otherwise the " + + "latest revision is used. Read-only: never registers anything.", + ReadContext: dataSourcePlatformArtifactRead, + Schema: map[string]*schema.Schema{ + "nrn": { + Type: schema.TypeString, + Required: true, + Description: "The owner NRN the artifact is registered under.", + }, + "type": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice( + []string{"oci_image", "oras_artifact", "git_repository", "blob"}, + false, + ), + Description: "The artifact type.", + }, + "meta": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringIsJSON, + Description: "JSON object with the meta fields to match. Identity fields (e.g. url, " + + "registry+repository) select the artifact; per-revision fields (e.g. reference, " + + "digest) additionally select a specific revision.", + }, + "artifact_id": { + Type: schema.TypeString, + Computed: true, + Description: "The artifact (envelope) id — use as a BOM component's `resource_id`.", + }, + "revision_id": { + Type: schema.TypeString, + Computed: true, + Description: "The resolved revision id — use as a BOM component's `resource_revision_id`.", + }, + "revision_meta": { + Type: schema.TypeString, + Computed: true, + Description: "JSON object with the full meta blob of the resolved revision.", + }, + "visible_to": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "NRNs allowed to consume this artifact.", + }, + "latest_revision_id": { + Type: schema.TypeString, + Computed: true, + Description: "The artifact's most recent revision id.", + }, + }, + } +} + +// metaMatches reports whether every key in `wanted` is present in `actual` +// with an equal (JSON) value. +func metaMatches(wanted, actual map[string]interface{}) bool { + for key, wantedValue := range wanted { + actualValue, present := actual[key] + if !present { + return false + } + wantedJSON, err := json.Marshal(wantedValue) + if err != nil { + return false + } + actualJSON, err := json.Marshal(actualValue) + if err != nil { + return false + } + if string(wantedJSON) != string(actualJSON) { + return false + } + } + return true +} + +func dataSourcePlatformArtifactRead(_ context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + nullOps := m.(NullOps) + + nrn := d.Get("nrn").(string) + artifactType := d.Get("type").(string) + + var wantedMeta map[string]interface{} + if err := json.Unmarshal([]byte(d.Get("meta").(string)), &wantedMeta); err != nil { + return diag.FromErr(fmt.Errorf("error parsing meta JSON: %v", err)) + } + + artifacts, err := nullOps.ListPlatformArtifacts(nrn, artifactType) + if err != nil { + return diag.FromErr(err) + } + + // Identity match: the artifact whose identity_meta is a subset of the + // requested meta (e.g. matching url for git_repository). + var matched []*PlatformArtifact + for _, artifact := range artifacts { + if metaMatches(artifact.IdentityMeta, wantedMeta) { + matched = append(matched, artifact) + } + } + if len(matched) == 0 { + return diag.FromErr(fmt.Errorf("no %s artifact under %s matches meta %v", artifactType, nrn, wantedMeta)) + } + if len(matched) > 1 { + return diag.FromErr(fmt.Errorf("meta %v matches %d %s artifacts under %s; add identity fields to disambiguate", wantedMeta, len(matched), artifactType, nrn)) + } + artifact := matched[0] + + revisions, err := nullOps.ListPlatformArtifactRevisions(artifact.ResourceID) + if err != nil { + return diag.FromErr(err) + } + + // Revision match: newest revision whose meta carries every requested + // field. When only identity fields were requested every revision + // matches, so this resolves to the latest one. + var revision *PlatformArtifactRevision + for _, candidate := range revisions { + if metaMatches(wantedMeta, candidate.Meta) { + revision = candidate + break + } + } + if revision == nil { + return diag.FromErr(fmt.Errorf("artifact %s has no revision matching meta %v", artifact.ResourceID, wantedMeta)) + } + + revisionMetaJSON, err := json.Marshal(revision.Meta) + if err != nil { + return diag.FromErr(fmt.Errorf("error serializing revision meta to JSON: %v", err)) + } + + d.SetId(revision.ResourceRevisionID) + if err := d.Set("artifact_id", artifact.ResourceID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("revision_id", revision.ResourceRevisionID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("revision_meta", string(revisionMetaJSON)); err != nil { + return diag.FromErr(err) + } + if err := d.Set("visible_to", artifact.VisibleTo); err != nil { + return diag.FromErr(err) + } + if err := d.Set("latest_revision_id", artifact.LatestRevisionID); err != nil { + return diag.FromErr(err) + } + + return nil +} diff --git a/nullplatform/null_client.go b/nullplatform/null_client.go index e16ea2a..69648f5 100644 --- a/nullplatform/null_client.go +++ b/nullplatform/null_client.go @@ -237,6 +237,21 @@ type NullOps interface { GetProviderSpecification(specId string) (*ProviderSpecification, error) PatchProviderSpecification(specId string, s *ProviderSpecification) error DeleteProviderSpecification(specId string) error + + RegisterPlatformArtifact(r *PlatformArtifactRegistration) (*PlatformArtifactRevision, error) + GetPlatformArtifact(artifactID string) (*PlatformArtifact, error) + GetPlatformArtifactRevision(revisionID string) (*PlatformArtifactRevision, error) + ListPlatformArtifacts(nrn, artifactType string) ([]*PlatformArtifact, error) + ListPlatformArtifactRevisions(artifactID string) ([]*PlatformArtifactRevision, error) + + UpsertPackage(p *PackageUpsert) (*Package, error) + GetPackage(packageID string) (*Package, error) + PatchPackage(packageID string, p *PackagePatch) error + DeletePackage(packageID string) error + FindPackage(nrn, slug string) (*Package, error) + ListPackageRevisions(packageID string) ([]*PackageRevision, error) + SetPackageTag(packageID, name string, body *PackageTagSet) error + DeletePackageTag(packageID, name string) error } func (t *LoggingTransport) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/nullplatform/package.go b/nullplatform/package.go new file mode 100644 index 0000000..a82d6b7 --- /dev/null +++ b/nullplatform/package.go @@ -0,0 +1,295 @@ +package nullplatform + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +const PACKAGE_PATH = "/packages" + +// PackageComponent is one BOM entry: it pins an exact revision (snapshot) of +// a resource owned by another service. parent_id links action/link spec +// components to their owning spec component within the same BOM. +type PackageComponent struct { + Name string `json:"name"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + ResourceRevisionID string `json:"resource_revision_id"` + ParentID *string `json:"parent_id,omitempty"` +} + +// PackageUpsert is the PUT /packages body: an idempotent slug-keyed publish. +// Missing (nrn, slug) creates the package + first revision; existing ones +// publish a new revision. `default` promotes the published revision to the +// package default in the same call. +type PackageUpsert struct { + Nrn string `json:"nrn"` + Slug string `json:"slug"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Components []PackageComponent `json:"components,omitempty"` + VisibleTo []string `json:"visible_to,omitempty"` + Default bool `json:"default,omitempty"` +} + +// PackagePatch carries the mutable envelope fields for PATCH /packages/:id. +// DefaultRevisionID pins which revision services bind to by default; it must +// belong to the package (the API validates) and is mutually exclusive with +// `default: true` on a publish body. +type PackagePatch struct { + Name string `json:"name,omitempty"` + VisibleTo []string `json:"visible_to,omitempty"` + DefaultRevisionID string `json:"default_revision_id,omitempty"` +} + +// Package is the envelope returned by the package read endpoints, with the +// resolved BOM of the default (or latest) revision inlined as components. +type Package struct { + ID string `json:"id,omitempty"` + Nrn string `json:"nrn,omitempty"` + Slug string `json:"slug,omitempty"` + Name string `json:"name,omitempty"` + VisibleTo []string `json:"visible_to,omitempty"` + DefaultRevisionID string `json:"default_revision_id,omitempty"` + LatestRevisionID string `json:"latest_revision_id,omitempty"` + DefaultVersion string `json:"default_version,omitempty"` + LatestVersion string `json:"latest_version,omitempty"` + Tags []*PackageTag `json:"tags,omitempty"` + Components []PackageComponent `json:"components,omitempty"` +} + +// PackageTag is a named, movable pointer to one package revision (npm +// dist-tag model). System tags (default, latest) are read-only and are +// surfaced for information only — the resource manages user tags. +type PackageTag struct { + Name string `json:"name"` + RevisionID string `json:"revision_id,omitempty"` + Version string `json:"version,omitempty"` + System bool `json:"system"` +} + +// PackageTagSet is the PUT /packages/:id/tags/:name body: point the tag at a +// revision by id or by published version. +type PackageTagSet struct { + RevisionID string `json:"revision_id,omitempty"` + Version string `json:"version,omitempty"` +} + +// PackageRevision is one published, immutable revision of a package. +type PackageRevision struct { + ID string `json:"id,omitempty"` + PackageID string `json:"package_id,omitempty"` + Version string `json:"version,omitempty"` +} + +type packageListResponse struct { + Results []*Package `json:"results"` +} + +type packageRevisionListResponse struct { + Results []*PackageRevision `json:"results"` +} + +func (c *NullClient) UpsertPackage(p *PackageUpsert) (*Package, error) { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(p); err != nil { + return nil, fmt.Errorf("error encoding package: %v", err) + } + + res, err := c.MakeRequest("PUT", PACKAGE_PATH, &buf) + if err != nil { + return nil, fmt.Errorf("error making PUT request: %v", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %v", err) + } + + // 201 on create, 200 on publish over an existing package. + if res.StatusCode != http.StatusCreated && res.StatusCode != http.StatusOK { + var errResp ErrorResponse + if err := json.Unmarshal(body, &errResp); err == nil && errResp.Message != "" { + return nil, fmt.Errorf("API error publishing package: %s (Code: %s)", errResp.Message, errResp.Code) + } + return nil, fmt.Errorf("error publishing package, got status code: %d, body: %s", res.StatusCode, string(body)) + } + + pkg := &Package{} + if err := json.Unmarshal(body, pkg); err != nil { + return nil, fmt.Errorf("error decoding package: %v", err) + } + + return pkg, nil +} + +func (c *NullClient) GetPackage(packageID string) (*Package, error) { + path := fmt.Sprintf("%s/%s", PACKAGE_PATH, packageID) + + body, err := c.getJSON(path, "package") + if err != nil { + return nil, err + } + + pkg := &Package{} + if err := json.Unmarshal(body, pkg); err != nil { + return nil, fmt.Errorf("error decoding package: %v", err) + } + + return pkg, nil +} + +func (c *NullClient) PatchPackage(packageID string, p *PackagePatch) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(p); err != nil { + return fmt.Errorf("error encoding package patch: %v", err) + } + + path := fmt.Sprintf("%s/%s", PACKAGE_PATH, packageID) + + res, err := c.MakeRequest("PATCH", path, &buf) + if err != nil { + return fmt.Errorf("error making PATCH request: %v", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("error reading response body: %v", err) + } + + if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNoContent { + var errResp ErrorResponse + if err := json.Unmarshal(body, &errResp); err == nil && errResp.Message != "" { + return fmt.Errorf("API error patching package: %s (Code: %s)", errResp.Message, errResp.Code) + } + return fmt.Errorf("error patching package, got status code: %d, body: %s", res.StatusCode, string(body)) + } + + return nil +} + +// SetPackageTag points a user tag at a revision (create or move). The body +// carries either a revision id or a published version. +func (c *NullClient) SetPackageTag(packageID, name string, body *PackageTagSet) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(body); err != nil { + return fmt.Errorf("error encoding package tag: %v", err) + } + + path := fmt.Sprintf("%s/%s/tags/%s", PACKAGE_PATH, packageID, name) + + res, err := c.MakeRequest("PUT", path, &buf) + if err != nil { + return fmt.Errorf("error making PUT request: %v", err) + } + defer res.Body.Close() + + resBody, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("error reading response body: %v", err) + } + + if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated { + var errResp ErrorResponse + if err := json.Unmarshal(resBody, &errResp); err == nil && errResp.Message != "" { + return fmt.Errorf("API error setting package tag '%s': %s (Code: %s)", name, errResp.Message, errResp.Code) + } + return fmt.Errorf("error setting package tag '%s', got status code: %d, body: %s", name, res.StatusCode, string(resBody)) + } + + return nil +} + +// DeletePackageTag removes a user tag pointer; the revision is untouched. +func (c *NullClient) DeletePackageTag(packageID, name string) error { + path := fmt.Sprintf("%s/%s/tags/%s", PACKAGE_PATH, packageID, name) + + res, err := c.MakeRequest("DELETE", path, nil) + if err != nil { + return fmt.Errorf("error making DELETE request: %v", err) + } + defer res.Body.Close() + + resBody, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("error reading response body: %v", err) + } + + if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNoContent && res.StatusCode != http.StatusNotFound { + var errResp ErrorResponse + if err := json.Unmarshal(resBody, &errResp); err == nil && errResp.Message != "" { + return fmt.Errorf("API error deleting package tag '%s': %s (Code: %s)", name, errResp.Message, errResp.Code) + } + return fmt.Errorf("error deleting package tag '%s', got status code: %d, body: %s", name, res.StatusCode, string(resBody)) + } + + return nil +} + +func (c *NullClient) DeletePackage(packageID string) error { + path := fmt.Sprintf("%s/%s", PACKAGE_PATH, packageID) + + res, err := c.MakeRequest("DELETE", path, nil) + if err != nil { + return fmt.Errorf("error making DELETE request: %v", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("error reading response body: %v", err) + } + + if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusNoContent && res.StatusCode != http.StatusNotFound { + var errResp ErrorResponse + if err := json.Unmarshal(body, &errResp); err == nil && errResp.Message != "" { + return fmt.Errorf("API error deleting package: %s (Code: %s)", errResp.Message, errResp.Code) + } + return fmt.Errorf("error deleting package, got status code: %d, body: %s", res.StatusCode, string(body)) + } + + return nil +} + +// FindPackage resolves a package by its natural key (nrn, slug). +func (c *NullClient) FindPackage(nrn, slug string) (*Package, error) { + params := map[string]string{"nrn": nrn, "slug": slug} + path := fmt.Sprintf("%s%s", PACKAGE_PATH, c.PrepareQueryString(params)) + + body, err := c.getJSON(path, "packages") + if err != nil { + return nil, err + } + + response := &packageListResponse{} + if err := json.Unmarshal(body, response); err != nil { + return nil, fmt.Errorf("error decoding package list: %v", err) + } + + if len(response.Results) != 1 { + return nil, fmt.Errorf("expected exactly one package for nrn=%s slug=%s, got %d", nrn, slug, len(response.Results)) + } + + return response.Results[0], nil +} + +func (c *NullClient) ListPackageRevisions(packageID string) ([]*PackageRevision, error) { + path := fmt.Sprintf("%s/%s/revisions", PACKAGE_PATH, packageID) + + body, err := c.getJSON(path, "package revisions") + if err != nil { + return nil, err + } + + response := &packageRevisionListResponse{} + if err := json.Unmarshal(body, response); err != nil { + return nil, fmt.Errorf("error decoding package revision list: %v", err) + } + + return response.Results, nil +} diff --git a/nullplatform/platform_artifact.go b/nullplatform/platform_artifact.go new file mode 100644 index 0000000..8215724 --- /dev/null +++ b/nullplatform/platform_artifact.go @@ -0,0 +1,191 @@ +package nullplatform + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +const ( + ARTIFACT_PATH = "/artifacts" + ARTIFACT_REVISION_PATH = "/artifact_revision" +) + +// PlatformArtifactRegistration is the POST /artifacts request body. Register +// is an idempotent upsert: the per-type stable subset of meta identifies the +// artifact row, each unique meta blob mints (or reuses) one revision. +type PlatformArtifactRegistration struct { + Nrn string `json:"nrn"` + Type string `json:"type"` + Meta map[string]interface{} `json:"meta"` + VisibleTo []string `json:"visible_to,omitempty"` +} + +// PlatformArtifactRevision is the (artifact, revision) pair returned by +// register and by the revision read endpoints. The ids are what package BOM +// components pin as resource_id / resource_revision_id. +type PlatformArtifactRevision struct { + ResourceID string `json:"resource_id,omitempty"` + ResourceRevisionID string `json:"resource_revision_id,omitempty"` + Type string `json:"type,omitempty"` + Meta map[string]interface{} `json:"meta,omitempty"` + Nrn string `json:"nrn,omitempty"` + VisibleTo []string `json:"visible_to,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +// PlatformArtifact is the artifact envelope returned by GET /artifacts/:id. +type PlatformArtifact struct { + ResourceID string `json:"resource_id,omitempty"` + Type string `json:"type,omitempty"` + Nrn string `json:"nrn,omitempty"` + IdentityMeta map[string]interface{} `json:"identity_meta,omitempty"` + VisibleTo []string `json:"visible_to,omitempty"` + RevisionCount int `json:"revision_count,omitempty"` + LatestRevisionID string `json:"latest_revision_id,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +type platformArtifactListResponse struct { + Results []*PlatformArtifact `json:"results"` +} + +type platformArtifactRevisionListResponse struct { + Results []*PlatformArtifactRevision `json:"results"` +} + +func (c *NullClient) RegisterPlatformArtifact(r *PlatformArtifactRegistration) (*PlatformArtifactRevision, error) { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(r); err != nil { + return nil, fmt.Errorf("error encoding artifact registration: %v", err) + } + + res, err := c.MakeRequest("POST", ARTIFACT_PATH, &buf) + if err != nil { + return nil, fmt.Errorf("error making POST request: %v", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %v", err) + } + + // 201 on first registration, 200 when the (artifact, revision) pair is reused. + if res.StatusCode != http.StatusCreated && res.StatusCode != http.StatusOK { + var errResp ErrorResponse + if err := json.Unmarshal(body, &errResp); err == nil && errResp.Message != "" { + return nil, fmt.Errorf("API error registering artifact: %s (Code: %s)", errResp.Message, errResp.Code) + } + return nil, fmt.Errorf("error registering artifact, got status code: %d, body: %s", res.StatusCode, string(body)) + } + + revision := &PlatformArtifactRevision{} + if err := json.Unmarshal(body, revision); err != nil { + return nil, fmt.Errorf("error decoding artifact registration response: %v", err) + } + + return revision, nil +} + +func (c *NullClient) GetPlatformArtifact(artifactID string) (*PlatformArtifact, error) { + path := fmt.Sprintf("%s/%s", ARTIFACT_PATH, artifactID) + + body, err := c.getJSON(path, "artifact") + if err != nil { + return nil, err + } + + artifact := &PlatformArtifact{} + if err := json.Unmarshal(body, artifact); err != nil { + return nil, fmt.Errorf("error decoding artifact: %v", err) + } + + return artifact, nil +} + +// GetPlatformArtifactRevision resolves a revision directly by id via the +// standalone GET /artifact_revision/:id endpoint — no parent artifact id +// needed. +func (c *NullClient) GetPlatformArtifactRevision(revisionID string) (*PlatformArtifactRevision, error) { + path := fmt.Sprintf("%s/%s", ARTIFACT_REVISION_PATH, revisionID) + + body, err := c.getJSON(path, "artifact revision") + if err != nil { + return nil, err + } + + revision := &PlatformArtifactRevision{} + if err := json.Unmarshal(body, revision); err != nil { + return nil, fmt.Errorf("error decoding artifact revision: %v", err) + } + + return revision, nil +} + +func (c *NullClient) ListPlatformArtifacts(nrn, artifactType string) ([]*PlatformArtifact, error) { + params := map[string]string{} + if nrn != "" { + params["nrn"] = nrn + } + if artifactType != "" { + params["type"] = artifactType + } + + path := fmt.Sprintf("%s%s", ARTIFACT_PATH, c.PrepareQueryString(params)) + + body, err := c.getJSON(path, "artifacts") + if err != nil { + return nil, err + } + + response := &platformArtifactListResponse{} + if err := json.Unmarshal(body, response); err != nil { + return nil, fmt.Errorf("error decoding artifact list: %v", err) + } + + return response.Results, nil +} + +func (c *NullClient) ListPlatformArtifactRevisions(artifactID string) ([]*PlatformArtifactRevision, error) { + path := fmt.Sprintf("%s/%s/revisions", ARTIFACT_PATH, artifactID) + + body, err := c.getJSON(path, "artifact revisions") + if err != nil { + return nil, err + } + + response := &platformArtifactRevisionListResponse{} + if err := json.Unmarshal(body, response); err != nil { + return nil, fmt.Errorf("error decoding artifact revision list: %v", err) + } + + return response.Results, nil +} + +// getJSON performs a GET and returns the raw body on 200, mapping API error +// envelopes into readable errors otherwise. +func (c *NullClient) getJSON(path, entity string) ([]byte, error) { + res, err := c.MakeRequest("GET", path, nil) + if err != nil { + return nil, fmt.Errorf("error making GET request: %v", err) + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %v", err) + } + + if res.StatusCode != http.StatusOK { + var errResp ErrorResponse + if err := json.Unmarshal(body, &errResp); err == nil && errResp.Message != "" { + return nil, fmt.Errorf("API error getting %s: %s (Code: %s)", entity, errResp.Message, errResp.Code) + } + return nil, fmt.Errorf("error getting %s, got status code: %d, body: %s", entity, res.StatusCode, string(body)) + } + + return body, nil +} diff --git a/nullplatform/provider.go b/nullplatform/provider.go index 26f7e92..f954fb8 100644 --- a/nullplatform/provider.go +++ b/nullplatform/provider.go @@ -80,6 +80,8 @@ func Provider() *schema.Provider { "nullplatform_metadata": resourceMetadata(), "nullplatform_scope_type": resourceScopeType(), "nullplatform_provider_specification": resourceProviderSpecification(), + "nullplatform_artifact": resourcePlatformArtifact(), + "nullplatform_package": resourcePackage(), }, DataSourcesMap: map[string]*schema.Resource{ "nullplatform_dimension": dataSourceDimension(), @@ -92,6 +94,8 @@ func Provider() *schema.Provider { "nullplatform_scope_type": dataSourceScopeType(), "nullplatform_action_specification": dataSourceActionSpecification(), "nullplatform_action_specifications": dataSourceActionSpecifications(), + "nullplatform_artifact": dataSourcePlatformArtifact(), + "nullplatform_package": dataSourcePackage(), }, } diff --git a/nullplatform/provider_test.go b/nullplatform/provider_test.go index 89dbe05..34a78a5 100644 --- a/nullplatform/provider_test.go +++ b/nullplatform/provider_test.go @@ -62,6 +62,8 @@ func TestProvider_HasChildResources(t *testing.T) { "nullplatform_scope_type", "nullplatform_entity_hook_action", "nullplatform_provider_specification", + "nullplatform_artifact", + "nullplatform_package", } resources := nullplatform.Provider().ResourcesMap @@ -85,6 +87,8 @@ func TestProvider_HasChildDataSources(t *testing.T) { "nullplatform_scope_type", "nullplatform_action_specification", "nullplatform_action_specifications", + "nullplatform_artifact", + "nullplatform_package", } dataSources := nullplatform.Provider().DataSourcesMap diff --git a/nullplatform/resource_package.go b/nullplatform/resource_package.go new file mode 100644 index 0000000..7fb029f --- /dev/null +++ b/nullplatform/resource_package.go @@ -0,0 +1,381 @@ +package nullplatform + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourcePackage() *schema.Resource { + return &schema.Resource{ + Description: "The package resource publishes a nullplatform package: a versioned, immutable " + + "bill of materials (BOM) that pins exact revisions of service specifications, action/link " + + "specifications and artifacts. Applying a new `version` + `components` publishes a new " + + "revision through the idempotent slug-keyed publish (PUT /packages); previously published " + + "revisions are never mutated. The first publish sticks the package default to that " + + "revision; later publishes only move it when `default = true`.", + + Create: PackageCreate, + Read: PackageRead, + Update: PackageUpdate, + Delete: PackageDelete, + + Importer: &schema.ResourceImporter{ + StateContext: func(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + d.Set("id", d.Id()) + return []*schema.ResourceData{d}, nil + }, + }, + + Schema: map[string]*schema.Schema{ + "nrn": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The owner NRN of the package. Writes (publishes, patches, delete) are gated on it.", + }, + "slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "URL-safe identifier, unique per NRN. Together with nrn it is the publish key.", + }, + "name": { + Type: schema.TypeString, + Required: true, + Description: "Human-readable display name.", + }, + "version": { + Type: schema.TypeString, + Required: true, + Description: "Semver of the revision this configuration publishes. Bump it together with " + + "`components` changes to publish a new revision; re-applying the same version with the " + + "same components is an idempotent no-op.", + }, + "components": { + Type: schema.TypeList, + Required: true, + Description: "Bill of materials: one entry per component, each pinning an exact resource revision.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "Free-form component name, unique within the revision (e.g. \"spec\", \"runtime:main\").", + }, + "resource_type": { + Type: schema.TypeString, + Required: true, + Description: "Routing key identifying the owning service (e.g. \"service_specification\", \"action_specification\", \"artifact\").", + }, + "resource_id": { + Type: schema.TypeString, + Required: true, + Description: "UUID of the underlying resource at its owning service.", + }, + "resource_revision_id": { + Type: schema.TypeString, + Required: true, + Description: "UUID of the exact snapshot/revision this component pins.", + }, + "parent_id": { + Type: schema.TypeString, + Optional: true, + Description: "resource_id of the owning spec/link component in this BOM (required for action/link specification components).", + }, + }, + }, + }, + "visible_to": { + Type: schema.TypeList, + Optional: true, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "NRNs allowed to consume (read/link) this package. Supports trailing-wildcard " + + "scopes (\"organization=1:account=*\") and the global wildcard \"organization=*\" " + + "(requires the write action org-wide). Defaults to [nrn].", + }, + "default": { + Type: schema.TypeBool, + Optional: true, + Default: false, + ConflictsWith: []string{"default_version"}, + Description: "When true, every publish from this resource also promotes the published " + + "revision to the package default (one-shot bump-and-promote).", + }, + "default_revision_id": { + Type: schema.TypeString, + Computed: true, + Description: "Revision UUID services bind to by default.", + }, + "latest_revision_id": { + Type: schema.TypeString, + Computed: true, + Description: "Highest-semver revision UUID.", + }, + "default_version": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ConflictsWith: []string{"default"}, + Description: "Pin the package default to this published version (resolved to its " + + "revision id and PATCHed after each apply). Mutually exclusive with `default`. " + + "When omitted, reflects the server-side default.", + }, + "latest_version": { + Type: schema.TypeString, + Computed: true, + Description: "Semver of the latest revision.", + }, + "tags": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "User release tags as name => published version (e.g. { beta = \"1.2.0\" }). " + + "Movable pointers layered over default/latest; reserved names (default, latest) are not " + + "allowed here. Terraform manages exactly the tags listed — removing a key deletes the tag.", + }, + "published_revision_id": { + Type: schema.TypeString, + Computed: true, + Description: "Revision UUID published for the configured `version`.", + }, + }, + } +} + +func buildPackageUpsert(d *schema.ResourceData) *PackageUpsert { + upsert := &PackageUpsert{ + Nrn: d.Get("nrn").(string), + Slug: d.Get("slug").(string), + Name: d.Get("name").(string), + Version: d.Get("version").(string), + Default: d.Get("default").(bool), + } + + for _, raw := range d.Get("components").([]interface{}) { + component := raw.(map[string]interface{}) + entry := PackageComponent{ + Name: component["name"].(string), + ResourceType: component["resource_type"].(string), + ResourceID: component["resource_id"].(string), + ResourceRevisionID: component["resource_revision_id"].(string), + } + if parentID, ok := component["parent_id"].(string); ok && parentID != "" { + entry.ParentID = &parentID + } + upsert.Components = append(upsert.Components, entry) + } + + if raw, ok := d.GetOk("visible_to"); ok { + for _, entry := range raw.([]interface{}) { + upsert.VisibleTo = append(upsert.VisibleTo, entry.(string)) + } + } + + return upsert +} + +// configuredDefaultVersion distinguishes a user-set default_version from the +// computed server-side value the attribute also carries (Optional+Computed). +func configuredDefaultVersion(d *schema.ResourceData) (string, bool) { + raw := d.GetRawConfig() + if raw.IsNull() { + return "", false + } + attr := raw.GetAttr("default_version") + if attr.IsNull() { + return "", false + } + return attr.AsString(), true +} + +func pinDefaultVersion(nullOps NullOps, packageID, version string) error { + revisions, err := nullOps.ListPackageRevisions(packageID) + if err != nil { + return err + } + for _, revision := range revisions { + if revision.Version == version { + return nullOps.PatchPackage(packageID, &PackagePatch{DefaultRevisionID: revision.ID}) + } + } + return fmt.Errorf("cannot pin default_version: package %s has no published version %s", packageID, version) +} + +func PackageCreate(d *schema.ResourceData, m interface{}) error { + nullOps := m.(NullOps) + + pkg, err := nullOps.UpsertPackage(buildPackageUpsert(d)) + if err != nil { + return err + } + + d.SetId(pkg.ID) + + if version, configured := configuredDefaultVersion(d); configured { + if err := pinDefaultVersion(nullOps, pkg.ID, version); err != nil { + return err + } + } + + if err := applyPackageTags(nullOps, pkg.ID, nil, toTagMap(d.Get("tags"))); err != nil { + return err + } + + return PackageRead(d, m) +} + +// toTagMap coerces a Terraform map attribute into map[string]string. +func toTagMap(raw interface{}) map[string]string { + out := map[string]string{} + if raw == nil { + return out + } + for key, value := range raw.(map[string]interface{}) { + out[key] = value.(string) + } + return out +} + +// applyPackageTags reconciles declared tags: point added/changed names at +// their version, delete names that were dropped from config. +func applyPackageTags(nullOps NullOps, packageID string, previous, desired map[string]string) error { + for name, version := range desired { + if previous[name] == version { + continue + } + if err := nullOps.SetPackageTag(packageID, name, &PackageTagSet{Version: version}); err != nil { + return err + } + } + for name := range previous { + if _, keep := desired[name]; keep { + continue + } + if err := nullOps.DeletePackageTag(packageID, name); err != nil { + return err + } + } + return nil +} + +func PackageRead(d *schema.ResourceData, m interface{}) error { + nullOps := m.(NullOps) + + pkg, err := nullOps.GetPackage(d.Id()) + if err != nil { + return err + } + + if err := d.Set("nrn", pkg.Nrn); err != nil { + return err + } + if err := d.Set("slug", pkg.Slug); err != nil { + return err + } + if err := d.Set("name", pkg.Name); err != nil { + return err + } + if err := d.Set("visible_to", pkg.VisibleTo); err != nil { + return err + } + if err := d.Set("default_revision_id", pkg.DefaultRevisionID); err != nil { + return err + } + if err := d.Set("latest_revision_id", pkg.LatestRevisionID); err != nil { + return err + } + if err := d.Set("default_version", pkg.DefaultVersion); err != nil { + return err + } + if err := d.Set("latest_version", pkg.LatestVersion); err != nil { + return err + } + + // Reflect the user tags currently on the package (system tags default/ + // latest are surfaced through their own attributes, not here). + userTags := map[string]string{} + for _, tag := range pkg.Tags { + if tag.System { + continue + } + userTags[tag.Name] = tag.Version + } + if err := d.Set("tags", userTags); err != nil { + return err + } + + // Resolve the revision id of the configured version. Revisions are + // immutable, so this only changes when `version` does. + version := d.Get("version").(string) + revisions, err := nullOps.ListPackageRevisions(pkg.ID) + if err != nil { + return err + } + for _, revision := range revisions { + if revision.Version == version { + if err := d.Set("published_revision_id", revision.ID); err != nil { + return err + } + break + } + } + + // `components` deliberately stays as configured: it describes the BOM of + // the revision this resource published, while the API's resolved view + // follows the package default and would report drift that isn't ours. + + return nil +} + +func PackageUpdate(d *schema.ResourceData, m interface{}) error { + nullOps := m.(NullOps) + + published := false + if d.HasChange("version") || d.HasChange("components") || d.HasChange("visible_to") || d.HasChange("default") { + // Publishing is the natural write path and also carries the envelope + // fields (name, visible_to) along. + if _, err := nullOps.UpsertPackage(buildPackageUpsert(d)); err != nil { + return err + } + published = true + } + + if !published && d.HasChange("name") { + patch := &PackagePatch{Name: d.Get("name").(string)} + if err := nullOps.PatchPackage(d.Id(), patch); err != nil { + return err + } + } + + // Re-pin after every apply that has default_version configured: the + // publish above may have minted the version the pin points at, and the + // PATCH is idempotent. + if version, configured := configuredDefaultVersion(d); configured { + if err := pinDefaultVersion(nullOps, d.Id(), version); err != nil { + return err + } + } + + if d.HasChange("tags") { + previous, desired := d.GetChange("tags") + if err := applyPackageTags(nullOps, d.Id(), toTagMap(previous), toTagMap(desired)); err != nil { + return err + } + } + + return PackageRead(d, m) +} + +func PackageDelete(d *schema.ResourceData, m interface{}) error { + nullOps := m.(NullOps) + + if err := nullOps.DeletePackage(d.Id()); err != nil { + return fmt.Errorf("error deleting package %s: %v", d.Id(), err) + } + + d.SetId("") + return nil +} diff --git a/nullplatform/resource_platform_artifact.go b/nullplatform/resource_platform_artifact.go new file mode 100644 index 0000000..2bcb796 --- /dev/null +++ b/nullplatform/resource_platform_artifact.go @@ -0,0 +1,176 @@ +package nullplatform + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourcePlatformArtifact() *schema.Resource { + return &schema.Resource{ + Description: "The artifact resource registers a platform artifact revision: an immutable, " + + "content-addressed reference to something that lives outside nullplatform (an OCI image, " + + "a git repository at a reference, a blob). Registration is an idempotent upsert — the " + + "per-type stable subset of `meta` identifies the artifact, each unique `meta` blob pins " + + "one revision. The resource ID is the revision id, ready to be used as a package BOM " + + "component's `resource_revision_id`. Artifacts are immutable records: destroying this " + + "resource only removes it from Terraform state.", + + Create: PlatformArtifactCreate, + Read: PlatformArtifactRead, + Update: PlatformArtifactUpdate, + Delete: PlatformArtifactDelete, + + Importer: &schema.ResourceImporter{ + StateContext: func(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + d.Set("id", d.Id()) + return []*schema.ResourceData{d}, nil + }, + }, + + Schema: map[string]*schema.Schema{ + "nrn": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The owner NRN of the artifact. Writes (new revisions, re-scoping) are gated on it.", + }, + "type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice( + []string{"oci_image", "oras_artifact", "git_repository", "blob"}, + false, + ), + Description: "Artifact type; discriminates the `meta` shape (e.g. git_repository requires { url, reference }).", + }, + "meta": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringIsJSON, + DiffSuppressFunc: suppressEquivalentJSON, + Description: "JSON object with the flat meta blob for this revision. The per-type stable " + + "subset (e.g. git_repository: url; oci_image: registry+repository) identifies the " + + "artifact; the rest pins the revision (e.g. reference, digest). Changing it registers " + + "a new revision (new resource).", + }, + "visible_to": { + Type: schema.TypeList, + Optional: true, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "NRNs allowed to consume (read/link) this artifact. Supports trailing-wildcard " + + "scopes (\"organization=1:account=*\") and the global wildcard \"organization=*\" " + + "(requires the write action org-wide). Defaults to [nrn].", + }, + "artifact_id": { + Type: schema.TypeString, + Computed: true, + Description: "The artifact (envelope) id — what BOM components use as `resource_id`.", + }, + }, + } +} + +func buildArtifactRegistration(d *schema.ResourceData) (*PlatformArtifactRegistration, error) { + var meta map[string]interface{} + if err := json.Unmarshal([]byte(d.Get("meta").(string)), &meta); err != nil { + return nil, fmt.Errorf("error parsing artifact meta JSON: %v", err) + } + + registration := &PlatformArtifactRegistration{ + Nrn: d.Get("nrn").(string), + Type: d.Get("type").(string), + Meta: meta, + } + + if raw, ok := d.GetOk("visible_to"); ok { + for _, entry := range raw.([]interface{}) { + registration.VisibleTo = append(registration.VisibleTo, entry.(string)) + } + } + + return registration, nil +} + +func PlatformArtifactCreate(d *schema.ResourceData, m interface{}) error { + nullOps := m.(NullOps) + + registration, err := buildArtifactRegistration(d) + if err != nil { + return err + } + + revision, err := nullOps.RegisterPlatformArtifact(registration) + if err != nil { + return err + } + + d.SetId(revision.ResourceRevisionID) + + return PlatformArtifactRead(d, m) +} + +func PlatformArtifactRead(d *schema.ResourceData, m interface{}) error { + nullOps := m.(NullOps) + + revision, err := nullOps.GetPlatformArtifactRevision(d.Id()) + if err != nil { + return err + } + + metaJSON, err := json.Marshal(revision.Meta) + if err != nil { + return fmt.Errorf("error serializing artifact meta to JSON: %v", err) + } + + if err := d.Set("nrn", revision.Nrn); err != nil { + return err + } + if err := d.Set("type", revision.Type); err != nil { + return err + } + if err := d.Set("meta", string(metaJSON)); err != nil { + return err + } + if err := d.Set("visible_to", revision.VisibleTo); err != nil { + return err + } + if err := d.Set("artifact_id", revision.ResourceID); err != nil { + return err + } + + return nil +} + +// PlatformArtifactUpdate only handles visible_to: re-registering the same +// meta is the API's write path on the existing artifact and re-scopes who +// can consume it. Everything else is ForceNew. +func PlatformArtifactUpdate(d *schema.ResourceData, m interface{}) error { + nullOps := m.(NullOps) + + if d.HasChange("visible_to") { + registration, err := buildArtifactRegistration(d) + if err != nil { + return err + } + if _, err := nullOps.RegisterPlatformArtifact(registration); err != nil { + return err + } + } + + return PlatformArtifactRead(d, m) +} + +// PlatformArtifactDelete forgets the registration: artifacts are immutable, +// content-addressed records that package revisions may pin forever, so the +// API intentionally exposes no delete. +func PlatformArtifactDelete(d *schema.ResourceData, m interface{}) error { + d.SetId("") + return nil +}