apply method

Moves the node to what request declares.

Implementation

Future<BlueprintApplyResult> apply(BlueprintApplyRequest request) async {
  final resolved = request.blueprint;
  final ledger = await ledgers.read(resolved.blueprint.value);
  final reading = await _read(resolved);
  final notes = [...reading.notes];
  final planned = _diff(
    resolved,
    reading.states,
    ledger,
    notes,
    purgeAdopted: request.purgeAdopted,
  );

  // A dry run stops here, having run exactly the code a real one runs up to
  // this point. A dry run that took a different path would be a dry run that
  // lies about what the real one will do.
  if (request.dryRun) {
    return BlueprintApplyResult(
      requestId: request.requestId,
      success: true,
      changes: planned,
      appliedHash: ledger?.hash ?? '',
      notes: [...notes, 'dry run: nothing was changed'],
    );
  }

  // Adoption is a fact about history, not about this run: a resource is
  // adopted if it was already correct the **first** time this blueprint saw
  // it, which is why the ledger is consulted before the reading is.
  //
  // Re-deciding it every time would be wrong in the worst direction. A
  // resource this blueprint installed is, by the second apply, "already
  // correct" — so it would be marked adopted, and dropping it from the
  // blueprint later would leave it behind forever.
  final known = ledger?.entries ?? const <ResourceId, LedgerEntry>{};
  final adopted = <ResourceId>{
    for (final resource in resolved.resources)
      if (!known.containsKey(resource.id) &&
          (reading.states[resource.id]?.satisfies(resource.ensure) ?? false))
        resource.id,
    // Once adopted, always adopted: the machine's history does not change
    // because a later apply touched something else.
    for (final entry in known.values)
      if (entry.adopted) entry.id,
  };

  final done = <ResourceId, ResourceChange>{};
  final failed = <ResourceId>{};

  for (final change in planned) {
    // A resource whose dependency failed is not attempted, and says so. The
    // branches that do not depend on the failure carry on: a blueprint of
    // forty resources with one bad package should report the other
    // thirty-nine, not stop at the third.
    final declaration = _resourceFor(resolved, change.id);
    final blocker = _blockedBy(declaration, failed);
    if (blocker != null) {
      done[change.id] = change.completed(
        kind: ChangeKind.skipped,
        reason: 'skipped: $blocker failed',
      );
      failed.add(change.id);
      continue;
    }

    if (!change.kind.isWork) {
      done[change.id] = change;
      continue;
    }

    final applied = await _applyOne(
      change,
      declaration,
      reading.states[change.id],
    );
    done[change.id] = applied;
    if (applied.kind == ChangeKind.failed) failed.add(change.id);
  }

  final changes = [for (final change in planned) done[change.id] ?? change];

  // A resource we tried to remove and could not is still on the machine, and
  // so is still ours. It has to stay in the ledger or it is leaked: see
  // `Ledger.recording`.
  final unremoved = {
    for (final change in planned)
      if (change.kind == ChangeKind.remove &&
          done[change.id]?.kind != ChangeKind.remove)
        change.id,
  };

  // The ledger records what was *declared*, not what succeeded: a resource
  // whose install failed is still this system's responsibility, and forgetting
  // it would orphan whatever half of it landed.
  await ledgers.write(
    (ledger ?? Ledger.empty(resolved.blueprint, clock.now())).recording(
      resolved,
      clock.now(),
      adopted: request.purgeAdopted ? const {} : adopted,
      states: reading.states,
      retain: unremoved,
    ),
  );

  return BlueprintApplyResult(
    requestId: request.requestId,
    success: failed.isEmpty,
    changes: changes,
    appliedHash: resolved.hash,
    notes: notes,
  );
}